exp-node-server 1.1.7 → 2.0.0

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 (181) hide show
  1. package/README.md +82 -20
  2. package/dist/cli.js +525 -0
  3. package/package.json +29 -9
  4. package/templates/mongoose/.dockerignore +10 -0
  5. package/templates/mongoose/.env.example +56 -7
  6. package/templates/mongoose/.prettierignore +3 -0
  7. package/templates/mongoose/.prettierrc +7 -0
  8. package/templates/mongoose/Dockerfile +33 -0
  9. package/templates/mongoose/_gitignore +32 -0
  10. package/templates/mongoose/docker-compose.yml +21 -0
  11. package/templates/mongoose/eslint.config.js +35 -0
  12. package/templates/mongoose/package-lock.json +6377 -0
  13. package/templates/mongoose/package.json +49 -10
  14. package/templates/mongoose/readme.md +85 -0
  15. package/templates/mongoose/src/app/configs/index.ts +178 -26
  16. package/templates/mongoose/src/app/errors/zodError.ts +16 -16
  17. package/templates/mongoose/src/app/lib/db.ts +18 -0
  18. package/templates/mongoose/src/app/lib/logger.ts +12 -0
  19. package/templates/mongoose/src/app/middlewares/auth.ts +48 -44
  20. package/templates/mongoose/src/app/middlewares/global_error_handler.ts +59 -65
  21. package/templates/mongoose/src/app/middlewares/not_found_api.ts +7 -7
  22. package/templates/mongoose/src/app/middlewares/rate_limiter.ts +153 -0
  23. package/templates/mongoose/src/app/middlewares/request_validator.ts +11 -10
  24. package/templates/mongoose/src/app/middlewares/uploader.ts +17 -13
  25. package/templates/mongoose/src/app/modules/account/account.controller.ts +156 -0
  26. package/templates/mongoose/src/app/modules/account/account.interface.ts +65 -0
  27. package/templates/mongoose/src/app/modules/account/account.route.ts +101 -0
  28. package/templates/mongoose/src/app/modules/account/account.schema.ts +97 -0
  29. package/templates/mongoose/src/app/modules/account/account.service.ts +422 -0
  30. package/templates/mongoose/src/app/modules/account/account.swagger.ts +278 -0
  31. package/templates/mongoose/src/app/modules/account/account.validation.ts +63 -0
  32. package/templates/mongoose/src/app/modules/profile/profile.controller.ts +17 -0
  33. package/templates/mongoose/src/app/modules/profile/profile.interface.ts +7 -0
  34. package/templates/mongoose/src/app/modules/profile/profile.route.ts +28 -0
  35. package/templates/mongoose/src/app/modules/profile/profile.schema.ts +13 -0
  36. package/templates/mongoose/src/app/modules/profile/profile.service.ts +28 -0
  37. package/templates/mongoose/src/app/modules/profile/profile.swagger.ts +34 -0
  38. package/templates/mongoose/src/app/modules/profile/profile.validation.ts +8 -0
  39. package/templates/mongoose/src/app/passport-js/index.ts +87 -0
  40. package/templates/mongoose/src/app/passport-js/oauth-state.ts +150 -0
  41. package/templates/mongoose/src/app/passport-js/providers.ts +258 -0
  42. package/templates/mongoose/src/app/passport-js/social-account.ts +141 -0
  43. package/templates/mongoose/src/app/queues/agenda.ts +24 -0
  44. package/templates/mongoose/src/app/queues/email.queue.ts +53 -0
  45. package/templates/mongoose/src/app/queues/index.ts +13 -0
  46. package/templates/mongoose/src/app/templates/otpEmailTemplate.ts +185 -0
  47. package/templates/mongoose/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
  48. package/templates/mongoose/src/app/types/error.d.ts +7 -7
  49. package/templates/mongoose/src/app/types/express.d.ts +11 -0
  50. package/templates/mongoose/src/app/types/passport-oauth1.d.ts +51 -0
  51. package/templates/mongoose/src/app/utils/JWT.ts +38 -18
  52. package/templates/mongoose/src/app/utils/app_error.ts +10 -10
  53. package/templates/mongoose/src/app/utils/auth_cookie.ts +27 -0
  54. package/templates/mongoose/src/app/utils/catch_async.ts +8 -8
  55. package/templates/mongoose/src/app/utils/cloudinary.ts +26 -59
  56. package/templates/mongoose/src/app/utils/mail_sender.ts +26 -92
  57. package/templates/mongoose/src/app/utils/manage_response.ts +19 -19
  58. package/templates/mongoose/src/app/utils/otp.ts +20 -0
  59. package/templates/mongoose/src/app/utils/session.ts +55 -0
  60. package/templates/mongoose/src/app.ts +73 -42
  61. package/templates/mongoose/src/routes.ts +13 -13
  62. package/templates/mongoose/src/server.ts +38 -18
  63. package/templates/mongoose/src/swaggerOptions.ts +23 -23
  64. package/templates/mongoose/tests/app.test.ts +97 -0
  65. package/templates/mongoose/tests/global-setup.ts +15 -0
  66. package/templates/mongoose/tests/helpers/fixtures.ts +42 -0
  67. package/templates/mongoose/tests/routes/account.test.ts +614 -0
  68. package/templates/mongoose/tests/routes/profile.test.ts +95 -0
  69. package/templates/mongoose/tests/routes/social.test.ts +310 -0
  70. package/templates/mongoose/tests/setup.ts +25 -0
  71. package/templates/mongoose/tsconfig.json +16 -20
  72. package/templates/mongoose/vitest.config.ts +43 -0
  73. package/templates/prisma/.dockerignore +2 -0
  74. package/templates/prisma/.env.example +62 -6
  75. package/templates/prisma/.prettierignore +3 -0
  76. package/templates/prisma/.prettierrc +7 -0
  77. package/templates/prisma/.projectrc.json +1 -1
  78. package/templates/prisma/Dockerfile +21 -7
  79. package/templates/prisma/_gitignore +35 -0
  80. package/templates/prisma/docker-compose.yml +8 -7
  81. package/templates/prisma/eslint.config.js +35 -0
  82. package/templates/prisma/package-lock.json +7327 -0
  83. package/templates/prisma/package.json +47 -9
  84. package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
  85. package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
  86. package/templates/prisma/prisma/schema/schema.prisma +1 -2
  87. package/templates/prisma/prisma.config.ts +5 -5
  88. package/templates/prisma/readme.md +81 -0
  89. package/templates/prisma/src/app/configs/index.ts +175 -26
  90. package/templates/prisma/src/app/errors/zodError.ts +16 -16
  91. package/templates/prisma/src/app/lib/logger.ts +12 -0
  92. package/templates/prisma/src/app/lib/prisma.ts +4 -6
  93. package/templates/prisma/src/app/middlewares/auth.ts +37 -16
  94. package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
  95. package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
  96. package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
  97. package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
  98. package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
  99. package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
  100. package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
  101. package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
  102. package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
  103. package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
  104. package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
  105. package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
  106. package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
  107. package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
  108. package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
  109. package/templates/prisma/src/app/passport-js/index.ts +87 -0
  110. package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
  111. package/templates/prisma/src/app/passport-js/providers.ts +263 -0
  112. package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
  113. package/templates/prisma/src/app/queues/boss.ts +8 -0
  114. package/templates/prisma/src/app/queues/email.queue.ts +56 -0
  115. package/templates/prisma/src/app/queues/index.ts +14 -0
  116. package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
  117. package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
  118. package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
  119. package/templates/prisma/src/app/types/error.d.ts +7 -7
  120. package/templates/prisma/src/app/types/express.d.ts +11 -0
  121. package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
  122. package/templates/prisma/src/app/utils/JWT.ts +36 -17
  123. package/templates/prisma/src/app/utils/app_error.ts +10 -10
  124. package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
  125. package/templates/prisma/src/app/utils/catch_async.ts +8 -8
  126. package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
  127. package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
  128. package/templates/prisma/src/app/utils/manage_response.ts +19 -19
  129. package/templates/prisma/src/app/utils/otp.ts +20 -0
  130. package/templates/prisma/src/app/utils/session.ts +57 -0
  131. package/templates/prisma/src/app.ts +73 -42
  132. package/templates/prisma/src/routes.ts +12 -10
  133. package/templates/prisma/src/server.ts +37 -19
  134. package/templates/prisma/src/swaggerOptions.ts +22 -26
  135. package/templates/prisma/tests/app.test.ts +86 -0
  136. package/templates/prisma/tests/helpers/fixtures.ts +35 -0
  137. package/templates/prisma/tests/helpers/prisma.ts +32 -0
  138. package/templates/prisma/tests/routes/account.test.ts +627 -0
  139. package/templates/prisma/tests/routes/profile.test.ts +91 -0
  140. package/templates/prisma/tests/routes/social.test.ts +317 -0
  141. package/templates/prisma/tests/setup.ts +10 -0
  142. package/templates/prisma/tsconfig.json +16 -23
  143. package/templates/prisma/vitest.config.ts +38 -0
  144. package/bin/cli.js +0 -41
  145. package/src/createProject.js +0 -61
  146. package/src/generateModule.js +0 -93
  147. package/src/utils/getProjectType.js +0 -13
  148. package/src/utils/mongoose/controllerGenerator.js +0 -70
  149. package/src/utils/mongoose/routeGenerator.js +0 -27
  150. package/src/utils/mongoose/schemaGenerator.js +0 -11
  151. package/src/utils/mongoose/serviceGenerator.js +0 -49
  152. package/src/utils/mongoose/swaggerGenerator.js +0 -112
  153. package/src/utils/mongoose/validationGenerator.js +0 -13
  154. package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
  155. package/src/utils/prisma/prismaServiceGenerator.js +0 -47
  156. package/src/utils/routeInjector.js +0 -40
  157. package/src/utils/swaggerInjector.js +0 -52
  158. package/src/utils/validateModuleName.js +0 -17
  159. package/src/utils/validateProjectName.js +0 -15
  160. package/src/utils.js +0 -26
  161. package/templates/mongoose/src/app/errors/castError.ts +0 -23
  162. package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
  163. package/templates/mongoose/src/app/errors/validationError.ts +0 -25
  164. package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
  165. package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
  166. package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
  167. package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
  168. package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
  169. package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
  170. package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
  171. package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
  172. package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
  173. package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
  174. package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
  175. package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
  176. package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
  177. package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
  178. package/templates/mongoose/src/app/types/index.d.ts +0 -12
  179. package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
  180. package/templates/prisma/src/app/types/index.d.ts +0 -12
  181. package/templates/prisma/src/app/utils/otpGenerator.ts +0 -29
@@ -0,0 +1,87 @@
1
+ import type { Express, NextFunction, Request, Response } from 'express';
2
+ import { Router } from 'express';
3
+ import passport from 'passport';
4
+ import { configs } from '../configs/index.js';
5
+ import { logger } from '../lib/logger.js';
6
+ import { rateLimiters } from '../middlewares/rate_limiter.js';
7
+ import manageResponse from '../utils/manage_response.js';
8
+ import { getReturnTo } from './oauth-state.js';
9
+ import { enabledSocialProviders, type SocialProvider } from './providers.js';
10
+ import { createSocialLoginCode, SocialLoginError } from './social-account.js';
11
+
12
+ for (const provider of enabledSocialProviders) {
13
+ passport.use(provider.name, provider.createStrategy());
14
+ }
15
+
16
+ /** Mounts passport on the app. Logins are stateless, no session is needed. */
17
+ export const setupPassport = (app: Express) => {
18
+ app.use(passport.initialize());
19
+ };
20
+
21
+ /** Sends the browser back to the front-end with `?code=` or `?error=`. */
22
+ const redirectToFrontEnd = (res: Response, params: Record<string, string | undefined>) => {
23
+ const url = new URL(configs.social.redirect_url);
24
+ for (const [key, value] of Object.entries(params)) {
25
+ if (value) url.searchParams.set(key, value);
26
+ }
27
+ res.redirect(url.toString());
28
+ };
29
+
30
+ /** Maps a failed handshake to a short error code the front-end can show. */
31
+ const errorCode = (req: Request, err: unknown, info: unknown): string => {
32
+ if (err instanceof SocialLoginError) return err.code;
33
+ if (req.query.error === 'access_denied' || req.query.denied) return 'access_denied';
34
+ const message = err instanceof Error ? err.message : (info as { message?: string })?.message;
35
+ if (message === 'invalid_state') return 'invalid_state';
36
+ if (err) logger.error({ err }, 'Social login failed');
37
+ return 'social_login_failed';
38
+ };
39
+
40
+ const callback =
41
+ ({ name }: SocialProvider) =>
42
+ (req: Request, res: Response, next: NextFunction) => {
43
+ passport.authenticate(
44
+ name,
45
+ { session: false },
46
+ async (err: unknown, user: Express.User | false, info: unknown) => {
47
+ const returnTo = getReturnTo(res);
48
+ if (err || !user) {
49
+ return redirectToFrontEnd(res, { error: errorCode(req, err, info), returnTo });
50
+ }
51
+ try {
52
+ const code = await createSocialLoginCode(user.accountId);
53
+ redirectToFrontEnd(res, { code, returnTo });
54
+ } catch (e) {
55
+ next(e);
56
+ }
57
+ },
58
+ )(req, res, next);
59
+ };
60
+
61
+ /**
62
+ * `GET /providers` lists the enabled providers (for rendering login buttons),
63
+ * `GET /<provider>?returnTo=/path` starts a login, `GET /<provider>/callback` finishes it.
64
+ */
65
+ export const socialAuthRouter = Router();
66
+
67
+ socialAuthRouter.get('/providers', (_req, res) => {
68
+ manageResponse(res, {
69
+ statusCode: 200,
70
+ success: true,
71
+ message: 'Social login providers fetched successfully',
72
+ data: enabledSocialProviders.map(({ name, label }) => ({
73
+ name,
74
+ label,
75
+ url: `/api/auth/social/${name}`,
76
+ })),
77
+ });
78
+ });
79
+
80
+ for (const provider of enabledSocialProviders) {
81
+ socialAuthRouter.get(
82
+ `/${provider.name}`,
83
+ rateLimiters.social,
84
+ passport.authenticate(provider.name, { session: false }),
85
+ );
86
+ socialAuthRouter.get(`/${provider.name}/callback`, callback(provider));
87
+ }
@@ -0,0 +1,150 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ hkdfSync,
5
+ randomBytes,
6
+ timingSafeEqual,
7
+ } from 'node:crypto';
8
+ import type { CookieOptions, Request, Response } from 'express';
9
+ import { configs } from '../configs/index.js';
10
+
11
+ /**
12
+ * Stateless OAuth handshake storage. Instead of a server-side session, the data a
13
+ * provider round trip needs (CSRF state, PKCE verifier, OAuth 1.0a request token)
14
+ * lives in a short-lived, encrypted, httpOnly cookie scoped to the social routes.
15
+ * Works across instances and restarts, and nothing accumulates in memory.
16
+ */
17
+
18
+ const STATE_TTL_MS = 10 * 60 * 1000;
19
+
20
+ // separate key for this purpose, derived from the access token secret
21
+ const key = Buffer.from(hkdfSync('sha256', configs.jwt.access_token, '', 'oauth-state-cookie', 32));
22
+
23
+ const cookieOptions: CookieOptions = {
24
+ httpOnly: true,
25
+ secure: configs.is_production,
26
+ // the provider sends the user back with a top-level GET, which `lax` allows
27
+ sameSite: 'lax',
28
+ path: '/api/auth/social',
29
+ };
30
+
31
+ const cookieName = (provider: string) => `oauth_${provider}`;
32
+
33
+ const seal = (data: object): string => {
34
+ const iv = randomBytes(12);
35
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
36
+ const body = JSON.stringify({ ...data, exp: Date.now() + STATE_TTL_MS });
37
+ const encrypted = Buffer.concat([cipher.update(body, 'utf8'), cipher.final()]);
38
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
39
+ };
40
+
41
+ const unseal = <T>(value: unknown): T | undefined => {
42
+ if (typeof value !== 'string') return undefined;
43
+ try {
44
+ const raw = Buffer.from(value, 'base64url');
45
+ const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
46
+ decipher.setAuthTag(raw.subarray(12, 28));
47
+ const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
48
+ const data = JSON.parse(body.toString('utf8'));
49
+ return data.exp > Date.now() ? data : undefined;
50
+ } catch {
51
+ return undefined; // tampered, wrong key or malformed
52
+ }
53
+ };
54
+
55
+ const res = (req: Request): Response => req.res!;
56
+
57
+ /** Reads and clears the handshake cookie, so it can only be used once. */
58
+ const take = <T>(req: Request, provider: string): T | undefined => {
59
+ res(req).clearCookie(cookieName(provider), cookieOptions);
60
+ return unseal<T>(req.cookies?.[cookieName(provider)]);
61
+ };
62
+
63
+ const put = (req: Request, provider: string, data: object) => {
64
+ res(req).cookie(cookieName(provider), seal(data), { ...cookieOptions, maxAge: STATE_TTL_MS });
65
+ };
66
+
67
+ const safeEqual = (a: string, b: string) => {
68
+ const x = Buffer.from(a);
69
+ const y = Buffer.from(b);
70
+ return x.length === y.length && timingSafeEqual(x, y);
71
+ };
72
+
73
+ /**
74
+ * `?returnTo=` lets the front-end resume where the user was. Only same-site paths
75
+ * are kept (no `//host` or `\` tricks), so it can't become an open redirect.
76
+ */
77
+ const readReturnTo = (req: Request): string | undefined => {
78
+ const value = req.query.returnTo;
79
+ if (typeof value !== 'string' || value.length > 512) return undefined;
80
+ return /^\/(?![/\\])[^\\]*$/.test(value) ? value : undefined;
81
+ };
82
+
83
+ /** The `returnTo` saved when the login started, available in the callback. */
84
+ export const getReturnTo = (res: Response): string | undefined => res.locals.oauthReturnTo;
85
+
86
+ type OAuth2State = { handle: string; verifier?: string; returnTo?: string };
87
+
88
+ /** passport-oauth2 state store: `state` parameter (CSRF) + PKCE verifier. */
89
+ export class CookieStateStore {
90
+ constructor(private readonly provider: string) {}
91
+
92
+ store(
93
+ req: Request,
94
+ verifier: string | undefined,
95
+ _state: unknown,
96
+ _meta: unknown,
97
+ callback: (err: Error | null, handle?: string) => void,
98
+ ) {
99
+ const handle = randomBytes(24).toString('base64url');
100
+ put(req, this.provider, {
101
+ handle,
102
+ verifier,
103
+ returnTo: readReturnTo(req),
104
+ } satisfies OAuth2State);
105
+ callback(null, handle);
106
+ }
107
+
108
+ verify(
109
+ req: Request,
110
+ providedState: unknown,
111
+ callback: (err: Error | null, ok: string | boolean, info?: { message: string }) => void,
112
+ ) {
113
+ const saved = take<OAuth2State>(req, this.provider);
114
+ if (!saved || typeof providedState !== 'string' || !safeEqual(saved.handle, providedState)) {
115
+ return callback(null, false, { message: 'invalid_state' });
116
+ }
117
+ res(req).locals.oauthReturnTo = saved.returnTo;
118
+ // a string tells passport-oauth2 to send it as the PKCE `code_verifier`
119
+ callback(null, saved.verifier ?? true);
120
+ }
121
+ }
122
+
123
+ type OAuth1State = { token: string; tokenSecret: string; returnTo?: string };
124
+
125
+ /** passport-oauth1 request token store (Twitter / X). */
126
+ export class CookieRequestTokenStore {
127
+ constructor(private readonly provider: string) {}
128
+
129
+ get(req: Request, token: string, callback: (err: Error | null, tokenSecret?: string) => void) {
130
+ const saved = take<OAuth1State>(req, this.provider);
131
+ if (!saved || typeof token !== 'string' || !safeEqual(saved.token, token)) {
132
+ return callback(new Error('invalid_state'));
133
+ }
134
+ res(req).locals.oauthReturnTo = saved.returnTo;
135
+ callback(null, saved.tokenSecret);
136
+ }
137
+
138
+ set(req: Request, token: string, tokenSecret: string, callback: (err?: Error) => void) {
139
+ put(req, this.provider, {
140
+ token,
141
+ tokenSecret,
142
+ returnTo: readReturnTo(req),
143
+ } satisfies OAuth1State);
144
+ callback();
145
+ }
146
+
147
+ destroy(_req: Request, _token: string, callback: (err?: Error) => void) {
148
+ callback(); // already cleared by get()
149
+ }
150
+ }
@@ -0,0 +1,263 @@
1
+ import type { PROVIDER } from '@prisma/client';
2
+ import type { Profile, Strategy } from 'passport';
3
+ import { Strategy as FacebookStrategy } from 'passport-facebook';
4
+ import { Strategy as GithubStrategy } from 'passport-github';
5
+ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
6
+ import OAuth1Strategy, { InternalOAuthError as OAuth1Error } from 'passport-oauth1';
7
+ import OAuth2Strategy, { InternalOAuthError } from 'passport-oauth2';
8
+ import { configs } from '../configs/index.js';
9
+ import { CookieRequestTokenStore, CookieStateStore } from './oauth-state.js';
10
+ import { findOrCreateSocialAccount } from './social-account.js';
11
+
12
+ type Done = (err: unknown, user?: Express.User | false) => void;
13
+
14
+ export type SocialProviderName =
15
+ | 'google'
16
+ | 'facebook'
17
+ | 'github'
18
+ | 'twitter'
19
+ | 'linkedin'
20
+ | 'discord';
21
+
22
+ export interface SocialProvider {
23
+ /** Passport strategy name, also used in the route: `/api/auth/social/<name>`. */
24
+ name: SocialProviderName;
25
+ /** Human readable name, e.g. for login buttons. */
26
+ label: string;
27
+ provider: PROVIDER;
28
+ enabled: boolean;
29
+ createStrategy: () => Strategy;
30
+ }
31
+
32
+ /** Shared verify callback: map the provider profile to one of our accounts. */
33
+ const verify =
34
+ (provider: PROVIDER) =>
35
+ (_token: string, _tokenSecret: string, profile: Profile, done: Done): void => {
36
+ findOrCreateSocialAccount(profile, provider).then(
37
+ (user) => done(null, user),
38
+ (err) => done(err),
39
+ );
40
+ };
41
+
42
+ /**
43
+ * Minimal OAuth 2.0 strategy for providers without a maintained passport package:
44
+ * fetches `profileURL` with the access token and maps the JSON to a passport profile.
45
+ */
46
+ class JsonProfileStrategy extends OAuth2Strategy {
47
+ constructor(
48
+ name: string,
49
+ options: OAuth2Strategy.StrategyOptions,
50
+ private readonly profileURL: string,
51
+ private readonly toProfile: (json: Record<string, unknown>) => Profile,
52
+ ) {
53
+ super(options, verify(name.toUpperCase() as PROVIDER));
54
+ this.name = name;
55
+ this._oauth2.useAuthorizationHeaderforGET(true);
56
+ }
57
+
58
+ override userProfile(accessToken: string, done: (err?: unknown, profile?: Profile) => void) {
59
+ this._oauth2.get(this.profileURL, accessToken, (err, body) => {
60
+ if (err) return done(new InternalOAuthError('Failed to fetch user profile', err));
61
+ try {
62
+ done(null, this.toProfile(JSON.parse(String(body))));
63
+ } catch (e) {
64
+ done(e);
65
+ }
66
+ });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * X (Twitter) login over OAuth 1.0a, which is what returns the user's email. Built on
72
+ * passport-oauth1 directly: the old passport-twitter package is unmaintained and
73
+ * pulls in a vulnerable XML parser.
74
+ */
75
+ class TwitterStrategy extends OAuth1Strategy {
76
+ constructor(options: { consumerKey: string; consumerSecret: string; callbackURL: string }) {
77
+ super(
78
+ {
79
+ ...options,
80
+ requestTokenURL: 'https://api.twitter.com/oauth/request_token',
81
+ accessTokenURL: 'https://api.twitter.com/oauth/access_token',
82
+ userAuthorizationURL: 'https://api.twitter.com/oauth/authenticate',
83
+ // the request token lives in an encrypted cookie instead of a session
84
+ requestTokenStore: new CookieRequestTokenStore('twitter'),
85
+ },
86
+ verify('TWITTER'),
87
+ );
88
+ this.name = 'twitter';
89
+ }
90
+
91
+ override userProfile(
92
+ token: string,
93
+ tokenSecret: string,
94
+ _params: unknown,
95
+ done: (err?: unknown, profile?: Profile) => void,
96
+ ) {
97
+ const url =
98
+ 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true&skip_status=true';
99
+ this._oauth.get(url, token, tokenSecret, (err, body) => {
100
+ if (err) return done(new OAuth1Error('Failed to fetch user profile', err));
101
+ try {
102
+ const json = JSON.parse(String(body));
103
+ done(null, {
104
+ provider: 'twitter',
105
+ id: String(json.id_str),
106
+ displayName: str(json.name) ?? str(json.screen_name) ?? '',
107
+ // X only returns the email when it is verified
108
+ emails: json.email ? [{ value: String(json.email) }] : [],
109
+ photos: str(json.profile_image_url_https)
110
+ ? [{ value: json.profile_image_url_https }]
111
+ : [],
112
+ });
113
+ } catch (e) {
114
+ done(e);
115
+ }
116
+ });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * `state` (login CSRF protection) and optional PKCE, kept in an encrypted cookie.
122
+ * Typed as `object` because the strategy typings don't all declare these options.
123
+ */
124
+ const oauthState = (name: SocialProviderName, pkce = false): object => ({
125
+ store: new CookieStateStore(name),
126
+ state: true,
127
+ ...(pkce && { pkce: true }),
128
+ });
129
+
130
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
131
+
132
+ const { google, facebook, github, twitter, linkedin, discord } = configs.social;
133
+
134
+ // every OAuth 2.0 provider gets `state`; PKCE is enabled where the provider supports it
135
+ export const socialProviders: SocialProvider[] = [
136
+ {
137
+ name: 'google',
138
+ label: 'Google',
139
+ provider: 'GOOGLE',
140
+ enabled: google.enabled,
141
+ createStrategy: () =>
142
+ new GoogleStrategy(
143
+ {
144
+ clientID: google.client_id,
145
+ clientSecret: google.client_secret,
146
+ callbackURL: google.callback_url,
147
+ scope: ['openid', 'email', 'profile'],
148
+ ...oauthState('google', true),
149
+ },
150
+ verify('GOOGLE'),
151
+ ),
152
+ },
153
+ {
154
+ name: 'facebook',
155
+ label: 'Facebook',
156
+ provider: 'FACEBOOK',
157
+ enabled: facebook.enabled,
158
+ createStrategy: () =>
159
+ new FacebookStrategy(
160
+ {
161
+ clientID: facebook.client_id,
162
+ clientSecret: facebook.client_secret,
163
+ callbackURL: facebook.callback_url,
164
+ scope: ['email', 'public_profile'],
165
+ profileFields: ['id', 'displayName', 'emails', 'photos'],
166
+ ...oauthState('facebook'),
167
+ },
168
+ verify('FACEBOOK'),
169
+ ),
170
+ },
171
+ {
172
+ name: 'github',
173
+ label: 'GitHub',
174
+ provider: 'GITHUB',
175
+ enabled: github.enabled,
176
+ createStrategy: () =>
177
+ new GithubStrategy(
178
+ {
179
+ clientID: github.client_id,
180
+ clientSecret: github.client_secret,
181
+ callbackURL: github.callback_url,
182
+ scope: ['user:email'],
183
+ ...oauthState('github'),
184
+ },
185
+ verify('GITHUB'),
186
+ ),
187
+ },
188
+ {
189
+ name: 'twitter',
190
+ label: 'X (Twitter)',
191
+ provider: 'TWITTER',
192
+ enabled: twitter.enabled,
193
+ createStrategy: () =>
194
+ new TwitterStrategy({
195
+ consumerKey: twitter.client_id,
196
+ consumerSecret: twitter.client_secret,
197
+ callbackURL: twitter.callback_url,
198
+ }),
199
+ },
200
+ {
201
+ // "Sign In with LinkedIn using OpenID Connect"
202
+ name: 'linkedin',
203
+ label: 'LinkedIn',
204
+ provider: 'LINKEDIN',
205
+ enabled: linkedin.enabled,
206
+ createStrategy: () =>
207
+ new JsonProfileStrategy(
208
+ 'linkedin',
209
+ {
210
+ authorizationURL: 'https://www.linkedin.com/oauth/v2/authorization',
211
+ tokenURL: 'https://www.linkedin.com/oauth/v2/accessToken',
212
+ clientID: linkedin.client_id,
213
+ clientSecret: linkedin.client_secret,
214
+ callbackURL: linkedin.callback_url,
215
+ scope: ['openid', 'profile', 'email'],
216
+ ...oauthState('linkedin'),
217
+ },
218
+ 'https://api.linkedin.com/v2/userinfo',
219
+ (json) => ({
220
+ provider: 'linkedin',
221
+ id: String(json.sub),
222
+ displayName: str(json.name) ?? '',
223
+ emails: json.email
224
+ ? [{ value: String(json.email), verified: json.email_verified === true } as never]
225
+ : [],
226
+ photos: str(json.picture) ? [{ value: String(json.picture) }] : [],
227
+ }),
228
+ ),
229
+ },
230
+ {
231
+ name: 'discord',
232
+ label: 'Discord',
233
+ provider: 'DISCORD',
234
+ enabled: discord.enabled,
235
+ createStrategy: () =>
236
+ new JsonProfileStrategy(
237
+ 'discord',
238
+ {
239
+ authorizationURL: 'https://discord.com/oauth2/authorize',
240
+ tokenURL: 'https://discord.com/api/oauth2/token',
241
+ clientID: discord.client_id,
242
+ clientSecret: discord.client_secret,
243
+ callbackURL: discord.callback_url,
244
+ scope: ['identify', 'email'],
245
+ ...oauthState('discord'),
246
+ },
247
+ 'https://discord.com/api/users/@me',
248
+ (json) => ({
249
+ provider: 'discord',
250
+ id: String(json.id),
251
+ displayName: str(json.global_name) ?? str(json.username) ?? '',
252
+ emails: json.email
253
+ ? [{ value: String(json.email), verified: json.verified === true } as never]
254
+ : [],
255
+ photos: str(json.avatar)
256
+ ? [{ value: `https://cdn.discordapp.com/avatars/${json.id}/${json.avatar}.png` }]
257
+ : [],
258
+ }),
259
+ ),
260
+ },
261
+ ];
262
+
263
+ export const enabledSocialProviders = socialProviders.filter((p) => p.enabled);
@@ -0,0 +1,147 @@
1
+ import type { PROVIDER } from '@prisma/client';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import type { Profile } from 'passport';
4
+ import { prisma } from '../lib/prisma.js';
5
+ import type { TokenUser } from '../utils/JWT.js';
6
+
7
+ /** How long the front-end has to exchange the code from the social login redirect. */
8
+ export const SOCIAL_LOGIN_CODE_TTL_MS = 60 * 1000;
9
+
10
+ /** A failed social login. `code` is sent to the front-end as `?error=<code>`. */
11
+ export class SocialLoginError extends Error {
12
+ constructor(
13
+ public code: 'email_unverified' | 'account_disabled' | 'invalid_profile',
14
+ message: string,
15
+ ) {
16
+ super(message);
17
+ }
18
+ }
19
+
20
+ type ProfileEmail = { value: string; verified?: boolean | string; primary?: boolean };
21
+
22
+ const isVerified = (e: ProfileEmail) => e.verified === true || e.verified === 'true';
23
+
24
+ // Facebook and X only ever return confirmed addresses; the others say whether the
25
+ // address is verified, and unverified ones are ignored
26
+ const ONLY_RETURNS_VERIFIED_EMAILS: PROVIDER[] = ['FACEBOOK', 'TWITTER'];
27
+
28
+ /** Picks an email we can trust, preferring the primary one. */
29
+ const pickEmail = (profile: Profile, provider: PROVIDER): string | undefined => {
30
+ const emails = (profile.emails ?? []) as ProfileEmail[];
31
+ const trusted = ONLY_RETURNS_VERIFIED_EMAILS.includes(provider)
32
+ ? emails
33
+ : emails.filter(isVerified);
34
+ return (trusted.find((e) => e.primary) ?? trusted[0])?.value?.toLowerCase();
35
+ };
36
+
37
+ const userSelect = { id: true, email: true, role: true, isDeleted: true, tokenVersion: true };
38
+
39
+ const toUser = (account: {
40
+ id: string;
41
+ email: string;
42
+ role: TokenUser['role'];
43
+ isDeleted: boolean;
44
+ tokenVersion: number;
45
+ }): TokenUser => {
46
+ if (account.isDeleted) {
47
+ throw new SocialLoginError('account_disabled', 'Account is deleted');
48
+ }
49
+ return {
50
+ accountId: account.id,
51
+ email: account.email,
52
+ role: account.role,
53
+ tokenVersion: account.tokenVersion,
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Finds or creates the account for a social login and returns the user for `req.user`.
59
+ *
60
+ * 1. A provider identity that was used before logs straight into its account.
61
+ * 2. Otherwise the provider's verified email links the identity to the account with
62
+ * that email, or creates a new account.
63
+ */
64
+ export const findOrCreateSocialAccount = async (
65
+ profile: Profile,
66
+ provider: PROVIDER,
67
+ ): Promise<TokenUser> => {
68
+ const providerUserId = profile.id ? String(profile.id) : '';
69
+ if (!providerUserId) {
70
+ throw new SocialLoginError('invalid_profile', 'The provider did not return a user id');
71
+ }
72
+
73
+ const linked = await prisma.socialAccount.findUnique({
74
+ where: { provider_providerUserId: { provider, providerUserId } },
75
+ select: { account: { select: userSelect } },
76
+ });
77
+ if (linked) return toUser(linked.account);
78
+
79
+ const email = pickEmail(profile, provider);
80
+ if (!email) {
81
+ throw new SocialLoginError(
82
+ 'email_unverified',
83
+ `Your ${provider.toLowerCase()} account doesn't have a verified email address.`,
84
+ );
85
+ }
86
+
87
+ const link = { create: { provider, providerUserId } };
88
+ const existing = await prisma.account.findUnique({
89
+ where: { email },
90
+ select: { ...userSelect, isAccountVerified: true },
91
+ });
92
+
93
+ if (!existing) {
94
+ const account = await prisma.account.create({
95
+ data: {
96
+ email,
97
+ password: null,
98
+ provider,
99
+ isAccountVerified: true,
100
+ profile: {
101
+ create: {
102
+ fullName: profile.displayName || email.split('@')[0]!,
103
+ profilePhoto: profile.photos?.[0]?.value ?? null,
104
+ },
105
+ },
106
+ socialAccounts: link,
107
+ },
108
+ select: userSelect,
109
+ });
110
+ return toUser(account);
111
+ }
112
+
113
+ toUser(existing); // rejects deleted accounts before linking anything
114
+
115
+ const account = await prisma.account.update({
116
+ where: { id: existing.id },
117
+ data: {
118
+ socialAccounts: link,
119
+ // The provider just proved ownership of this email, but nobody ever proved the
120
+ // password on this unverified account belongs to the owner (it may have been
121
+ // registered by someone else). Verify the account and drop that password.
122
+ ...(!existing.isAccountVerified && {
123
+ isAccountVerified: true,
124
+ password: null,
125
+ lastOtp: null,
126
+ otpAttempts: 0,
127
+ }),
128
+ },
129
+ select: userSelect,
130
+ });
131
+ return toUser(account);
132
+ };
133
+
134
+ export const hashLoginCode = (code: string) => createHash('sha256').update(code).digest('hex');
135
+
136
+ /** Creates the single-use code the front-end exchanges for an access token. */
137
+ export const createSocialLoginCode = async (accountId: string): Promise<string> => {
138
+ const code = randomBytes(32).toString('base64url');
139
+ await prisma.socialLoginCode.create({
140
+ data: {
141
+ codeHash: hashLoginCode(code),
142
+ accountId,
143
+ expiresAt: new Date(Date.now() + SOCIAL_LOGIN_CODE_TTL_MS),
144
+ },
145
+ });
146
+ return code;
147
+ };
@@ -0,0 +1,8 @@
1
+ import { PgBoss } from 'pg-boss';
2
+ import { configs } from '../configs/index.js';
3
+ import { logger } from '../lib/logger.js';
4
+
5
+ // pg-boss stores jobs in its own `pgboss` schema in the app's Postgres database
6
+ export const boss = new PgBoss({ connectionString: configs.db_url });
7
+
8
+ boss.on('error', (error) => logger.error(error, 'pg-boss error'));