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,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,258 @@
1
+ import type { PROVIDER } from '../modules/account/account.interface.js';
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' | 'facebook' | 'github' | 'twitter' | 'linkedin' | 'discord';
16
+
17
+ export interface SocialProvider {
18
+ /** Passport strategy name, also used in the route: `/api/auth/social/<name>`. */
19
+ name: SocialProviderName;
20
+ /** Human readable name, e.g. for login buttons. */
21
+ label: string;
22
+ provider: PROVIDER;
23
+ enabled: boolean;
24
+ createStrategy: () => Strategy;
25
+ }
26
+
27
+ /** Shared verify callback: map the provider profile to one of our accounts. */
28
+ const verify =
29
+ (provider: PROVIDER) =>
30
+ (_token: string, _tokenSecret: string, profile: Profile, done: Done): void => {
31
+ findOrCreateSocialAccount(profile, provider).then(
32
+ (user) => done(null, user),
33
+ (err) => done(err),
34
+ );
35
+ };
36
+
37
+ /**
38
+ * Minimal OAuth 2.0 strategy for providers without a maintained passport package:
39
+ * fetches `profileURL` with the access token and maps the JSON to a passport profile.
40
+ */
41
+ class JsonProfileStrategy extends OAuth2Strategy {
42
+ constructor(
43
+ name: string,
44
+ options: OAuth2Strategy.StrategyOptions,
45
+ private readonly profileURL: string,
46
+ private readonly toProfile: (json: Record<string, unknown>) => Profile,
47
+ ) {
48
+ super(options, verify(name.toUpperCase() as PROVIDER));
49
+ this.name = name;
50
+ this._oauth2.useAuthorizationHeaderforGET(true);
51
+ }
52
+
53
+ override userProfile(accessToken: string, done: (err?: unknown, profile?: Profile) => void) {
54
+ this._oauth2.get(this.profileURL, accessToken, (err, body) => {
55
+ if (err) return done(new InternalOAuthError('Failed to fetch user profile', err));
56
+ try {
57
+ done(null, this.toProfile(JSON.parse(String(body))));
58
+ } catch (e) {
59
+ done(e);
60
+ }
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * X (Twitter) login over OAuth 1.0a, which is what returns the user's email. Built on
67
+ * passport-oauth1 directly: the old passport-twitter package is unmaintained and
68
+ * pulls in a vulnerable XML parser.
69
+ */
70
+ class TwitterStrategy extends OAuth1Strategy {
71
+ constructor(options: { consumerKey: string; consumerSecret: string; callbackURL: string }) {
72
+ super(
73
+ {
74
+ ...options,
75
+ requestTokenURL: 'https://api.twitter.com/oauth/request_token',
76
+ accessTokenURL: 'https://api.twitter.com/oauth/access_token',
77
+ userAuthorizationURL: 'https://api.twitter.com/oauth/authenticate',
78
+ // the request token lives in an encrypted cookie instead of a session
79
+ requestTokenStore: new CookieRequestTokenStore('twitter'),
80
+ },
81
+ verify('TWITTER'),
82
+ );
83
+ this.name = 'twitter';
84
+ }
85
+
86
+ override userProfile(
87
+ token: string,
88
+ tokenSecret: string,
89
+ _params: unknown,
90
+ done: (err?: unknown, profile?: Profile) => void,
91
+ ) {
92
+ const url =
93
+ 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true&skip_status=true';
94
+ this._oauth.get(url, token, tokenSecret, (err, body) => {
95
+ if (err) return done(new OAuth1Error('Failed to fetch user profile', err));
96
+ try {
97
+ const json = JSON.parse(String(body));
98
+ done(null, {
99
+ provider: 'twitter',
100
+ id: String(json.id_str),
101
+ displayName: str(json.name) ?? str(json.screen_name) ?? '',
102
+ // X only returns the email when it is verified
103
+ emails: json.email ? [{ value: String(json.email) }] : [],
104
+ photos: str(json.profile_image_url_https)
105
+ ? [{ value: json.profile_image_url_https }]
106
+ : [],
107
+ });
108
+ } catch (e) {
109
+ done(e);
110
+ }
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * `state` (login CSRF protection) and optional PKCE, kept in an encrypted cookie.
117
+ * Typed as `object` because the strategy typings don't all declare these options.
118
+ */
119
+ const oauthState = (name: SocialProviderName, pkce = false): object => ({
120
+ store: new CookieStateStore(name),
121
+ state: true,
122
+ ...(pkce && { pkce: true }),
123
+ });
124
+
125
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
126
+
127
+ const { google, facebook, github, twitter, linkedin, discord } = configs.social;
128
+
129
+ // every OAuth 2.0 provider gets `state`; PKCE is enabled where the provider supports it
130
+ export const socialProviders: SocialProvider[] = [
131
+ {
132
+ name: 'google',
133
+ label: 'Google',
134
+ provider: 'GOOGLE',
135
+ enabled: google.enabled,
136
+ createStrategy: () =>
137
+ new GoogleStrategy(
138
+ {
139
+ clientID: google.client_id,
140
+ clientSecret: google.client_secret,
141
+ callbackURL: google.callback_url,
142
+ scope: ['openid', 'email', 'profile'],
143
+ ...oauthState('google', true),
144
+ },
145
+ verify('GOOGLE'),
146
+ ),
147
+ },
148
+ {
149
+ name: 'facebook',
150
+ label: 'Facebook',
151
+ provider: 'FACEBOOK',
152
+ enabled: facebook.enabled,
153
+ createStrategy: () =>
154
+ new FacebookStrategy(
155
+ {
156
+ clientID: facebook.client_id,
157
+ clientSecret: facebook.client_secret,
158
+ callbackURL: facebook.callback_url,
159
+ scope: ['email', 'public_profile'],
160
+ profileFields: ['id', 'displayName', 'emails', 'photos'],
161
+ ...oauthState('facebook'),
162
+ },
163
+ verify('FACEBOOK'),
164
+ ),
165
+ },
166
+ {
167
+ name: 'github',
168
+ label: 'GitHub',
169
+ provider: 'GITHUB',
170
+ enabled: github.enabled,
171
+ createStrategy: () =>
172
+ new GithubStrategy(
173
+ {
174
+ clientID: github.client_id,
175
+ clientSecret: github.client_secret,
176
+ callbackURL: github.callback_url,
177
+ scope: ['user:email'],
178
+ ...oauthState('github'),
179
+ },
180
+ verify('GITHUB'),
181
+ ),
182
+ },
183
+ {
184
+ name: 'twitter',
185
+ label: 'X (Twitter)',
186
+ provider: 'TWITTER',
187
+ enabled: twitter.enabled,
188
+ createStrategy: () =>
189
+ new TwitterStrategy({
190
+ consumerKey: twitter.client_id,
191
+ consumerSecret: twitter.client_secret,
192
+ callbackURL: twitter.callback_url,
193
+ }),
194
+ },
195
+ {
196
+ // "Sign In with LinkedIn using OpenID Connect"
197
+ name: 'linkedin',
198
+ label: 'LinkedIn',
199
+ provider: 'LINKEDIN',
200
+ enabled: linkedin.enabled,
201
+ createStrategy: () =>
202
+ new JsonProfileStrategy(
203
+ 'linkedin',
204
+ {
205
+ authorizationURL: 'https://www.linkedin.com/oauth/v2/authorization',
206
+ tokenURL: 'https://www.linkedin.com/oauth/v2/accessToken',
207
+ clientID: linkedin.client_id,
208
+ clientSecret: linkedin.client_secret,
209
+ callbackURL: linkedin.callback_url,
210
+ scope: ['openid', 'profile', 'email'],
211
+ ...oauthState('linkedin'),
212
+ },
213
+ 'https://api.linkedin.com/v2/userinfo',
214
+ (json) => ({
215
+ provider: 'linkedin',
216
+ id: String(json.sub),
217
+ displayName: str(json.name) ?? '',
218
+ emails: json.email
219
+ ? [{ value: String(json.email), verified: json.email_verified === true } as never]
220
+ : [],
221
+ photos: str(json.picture) ? [{ value: String(json.picture) }] : [],
222
+ }),
223
+ ),
224
+ },
225
+ {
226
+ name: 'discord',
227
+ label: 'Discord',
228
+ provider: 'DISCORD',
229
+ enabled: discord.enabled,
230
+ createStrategy: () =>
231
+ new JsonProfileStrategy(
232
+ 'discord',
233
+ {
234
+ authorizationURL: 'https://discord.com/oauth2/authorize',
235
+ tokenURL: 'https://discord.com/api/oauth2/token',
236
+ clientID: discord.client_id,
237
+ clientSecret: discord.client_secret,
238
+ callbackURL: discord.callback_url,
239
+ scope: ['identify', 'email'],
240
+ ...oauthState('discord'),
241
+ },
242
+ 'https://discord.com/api/users/@me',
243
+ (json) => ({
244
+ provider: 'discord',
245
+ id: String(json.id),
246
+ displayName: str(json.global_name) ?? str(json.username) ?? '',
247
+ emails: json.email
248
+ ? [{ value: String(json.email), verified: json.verified === true } as never]
249
+ : [],
250
+ photos: str(json.avatar)
251
+ ? [{ value: `https://cdn.discordapp.com/avatars/${json.id}/${json.avatar}.png` }]
252
+ : [],
253
+ }),
254
+ ),
255
+ },
256
+ ];
257
+
258
+ export const enabledSocialProviders = socialProviders.filter((p) => p.enabled);
@@ -0,0 +1,141 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import type { Types } from 'mongoose';
3
+ import type { Profile } from 'passport';
4
+ import type { PROVIDER } from '../modules/account/account.interface.js';
5
+ import {
6
+ account_model,
7
+ createAccountWithProfile,
8
+ social_account_model,
9
+ social_login_code_model,
10
+ } from '../modules/account/account.schema.js';
11
+ import type { TokenUser } from '../utils/JWT.js';
12
+
13
+ /** How long the front-end has to exchange the code from the social login redirect. */
14
+ export const SOCIAL_LOGIN_CODE_TTL_MS = 60 * 1000;
15
+
16
+ /** A failed social login. `code` is sent to the front-end as `?error=<code>`. */
17
+ export class SocialLoginError extends Error {
18
+ constructor(
19
+ public code: 'email_unverified' | 'account_disabled' | 'invalid_profile',
20
+ message: string,
21
+ ) {
22
+ super(message);
23
+ }
24
+ }
25
+
26
+ type ProfileEmail = { value: string; verified?: boolean | string; primary?: boolean };
27
+
28
+ const isVerified = (e: ProfileEmail) => e.verified === true || e.verified === 'true';
29
+
30
+ // Facebook and X only ever return confirmed addresses; the others say whether the
31
+ // address is verified, and unverified ones are ignored
32
+ const ONLY_RETURNS_VERIFIED_EMAILS: PROVIDER[] = ['FACEBOOK', 'TWITTER'];
33
+
34
+ /** Picks an email we can trust, preferring the primary one. */
35
+ const pickEmail = (profile: Profile, provider: PROVIDER): string | undefined => {
36
+ const emails = (profile.emails ?? []) as ProfileEmail[];
37
+ const trusted = ONLY_RETURNS_VERIFIED_EMAILS.includes(provider)
38
+ ? emails
39
+ : emails.filter(isVerified);
40
+ return (trusted.find((e) => e.primary) ?? trusted[0])?.value?.toLowerCase();
41
+ };
42
+
43
+ const USER_FIELDS = 'email role isDeleted tokenVersion';
44
+
45
+ type AccountForToken = {
46
+ _id: Types.ObjectId;
47
+ email: string;
48
+ role: TokenUser['role'];
49
+ isDeleted: boolean;
50
+ tokenVersion: number;
51
+ };
52
+
53
+ const toUser = (account: AccountForToken): TokenUser => {
54
+ if (account.isDeleted) {
55
+ throw new SocialLoginError('account_disabled', 'Account is deleted');
56
+ }
57
+ return {
58
+ accountId: String(account._id),
59
+ email: account.email,
60
+ role: account.role,
61
+ tokenVersion: account.tokenVersion,
62
+ };
63
+ };
64
+
65
+ /**
66
+ * Finds or creates the account for a social login and returns the user for `req.user`.
67
+ *
68
+ * 1. A provider identity that was used before logs straight into its account.
69
+ * 2. Otherwise the provider's verified email links the identity to the account with
70
+ * that email, or creates a new account.
71
+ */
72
+ export const findOrCreateSocialAccount = async (
73
+ profile: Profile,
74
+ provider: PROVIDER,
75
+ ): Promise<TokenUser> => {
76
+ const providerUserId = profile.id ? String(profile.id) : '';
77
+ if (!providerUserId) {
78
+ throw new SocialLoginError('invalid_profile', 'The provider did not return a user id');
79
+ }
80
+
81
+ const linked = await social_account_model.findOne({ provider, providerUserId }).lean();
82
+ if (linked) {
83
+ const account = await account_model.findById(linked.accountId).select(USER_FIELDS).lean();
84
+ if (account) return toUser(account);
85
+ // the account was removed; drop the stale link and continue as a new login
86
+ await social_account_model.deleteOne({ _id: linked._id });
87
+ }
88
+
89
+ const email = pickEmail(profile, provider);
90
+ if (!email) {
91
+ throw new SocialLoginError(
92
+ 'email_unverified',
93
+ `Your ${provider.toLowerCase()} account doesn't have a verified email address.`,
94
+ );
95
+ }
96
+
97
+ const existing = await account_model
98
+ .findOne({ email })
99
+ .select(`${USER_FIELDS} isAccountVerified`)
100
+ .lean();
101
+
102
+ let account: AccountForToken;
103
+ if (!existing) {
104
+ account = await createAccountWithProfile(
105
+ { email, password: null, provider, isAccountVerified: true },
106
+ {
107
+ fullName: profile.displayName || email.split('@')[0]!,
108
+ profilePhoto: profile.photos?.[0]?.value ?? null,
109
+ },
110
+ );
111
+ } else {
112
+ toUser(existing); // rejects deleted accounts before linking anything
113
+ account = existing;
114
+
115
+ if (!existing.isAccountVerified) {
116
+ // The provider just proved ownership of this email, but nobody ever proved the
117
+ // password on this unverified account belongs to the owner (it may have been
118
+ // registered by someone else). Verify the account and drop that password.
119
+ await account_model.updateOne(
120
+ { _id: existing._id },
121
+ { $set: { isAccountVerified: true, password: null, lastOtp: null, otpAttempts: 0 } },
122
+ );
123
+ }
124
+ }
125
+
126
+ await social_account_model.create({ accountId: account._id, provider, providerUserId });
127
+ return toUser(account);
128
+ };
129
+
130
+ export const hashLoginCode = (code: string) => createHash('sha256').update(code).digest('hex');
131
+
132
+ /** Creates the single-use code the front-end exchanges for an access token. */
133
+ export const createSocialLoginCode = async (accountId: string): Promise<string> => {
134
+ const code = randomBytes(32).toString('base64url');
135
+ await social_login_code_model.create({
136
+ _id: hashLoginCode(code),
137
+ accountId,
138
+ expiresAt: new Date(Date.now() + SOCIAL_LOGIN_CODE_TTL_MS),
139
+ });
140
+ return code;
141
+ };
@@ -0,0 +1,24 @@
1
+ import { MongoBackend } from '@agendajs/mongo-backend';
2
+ import { Agenda } from 'agenda';
3
+ import mongoose from 'mongoose';
4
+ import { logger } from '../lib/logger.js';
5
+
6
+ /**
7
+ * Agenda stores jobs in the app's MongoDB (`agendaJobs` collection), so no Redis
8
+ * is needed. Created on start, once mongoose is connected, to reuse its connection.
9
+ */
10
+ let agenda: Agenda | undefined;
11
+
12
+ export const createAgenda = () => {
13
+ agenda = new Agenda({
14
+ backend: new MongoBackend({ mongo: mongoose.connection.db! }),
15
+ processEvery: '5 seconds',
16
+ });
17
+ agenda.on('error', (err) => logger.error(err, 'Agenda error'));
18
+ return agenda;
19
+ };
20
+
21
+ export const getAgenda = (): Agenda => {
22
+ if (!agenda) throw new Error('Queues are not started, call startQueues() first');
23
+ return agenda;
24
+ };
@@ -0,0 +1,53 @@
1
+ import { exponential, type Job } from 'agenda';
2
+ import { configs } from '../configs/index.js';
3
+ import { logger } from '../lib/logger.js';
4
+ import { otpEmailTemplate } from '../templates/otpEmailTemplate.js';
5
+ import { resetPasswordEmailTemplate } from '../templates/resetPasswordEmailTemplate.js';
6
+ import sendMail from '../utils/mail_sender.js';
7
+ import { getAgenda } from './agenda.js';
8
+
9
+ export const EMAIL_QUEUE = 'email';
10
+
11
+ /** Every email the app sends goes through this one queue. */
12
+ export type EmailJob =
13
+ | { type: 'verify-account'; email: string; otp: string; verificationLink: string }
14
+ | { type: 'reset-password'; email: string; resetLink: string };
15
+
16
+ const render = (job: EmailJob) => {
17
+ switch (job.type) {
18
+ case 'verify-account':
19
+ return {
20
+ subject: 'Verify Your Email',
21
+ htmlBody: otpEmailTemplate(job),
22
+ textBody: `Your verification code is ${job.otp}. Or verify using this link: ${job.verificationLink}`,
23
+ };
24
+ case 'reset-password':
25
+ return {
26
+ subject: 'Reset Your Password',
27
+ htmlBody: resetPasswordEmailTemplate(job.resetLink, configs.jwt.reset_expires),
28
+ textBody: `Reset your password using this link: ${job.resetLink}`,
29
+ };
30
+ }
31
+ };
32
+
33
+ export const defineEmailJob = () =>
34
+ getAgenda().define(
35
+ EMAIL_QUEUE,
36
+ async ({ attrs }: Job<EmailJob>) => {
37
+ const data = attrs.data;
38
+ try {
39
+ await sendMail({ to: data.email, ...render(data) });
40
+ logger.info(`Email (${data.type}) sent to ${data.email}`);
41
+ } catch (err) {
42
+ logger.error({ err, jobId: attrs._id }, `Failed to send ${data.type} email, will retry`);
43
+ throw err; // let Agenda retry the job
44
+ }
45
+ },
46
+ {
47
+ backoff: exponential({ delay: 10_000, factor: 2, maxRetries: 3 }),
48
+ // jobs carry OTPs and reset links, so don't keep them around
49
+ removeOnComplete: true,
50
+ },
51
+ );
52
+
53
+ export const enqueueEmail = (job: EmailJob) => getAgenda().now(EMAIL_QUEUE, job);
@@ -0,0 +1,13 @@
1
+ import { logger } from '../lib/logger.js';
2
+ import { createAgenda, getAgenda } from './agenda.js';
3
+ import { defineEmailJob } from './email.queue.js';
4
+
5
+ export const startQueues = async () => {
6
+ createAgenda();
7
+ defineEmailJob();
8
+ await getAgenda().start();
9
+ logger.info('Queues are running...');
10
+ };
11
+
12
+ // waits for running jobs to finish (up to 30s) instead of abandoning them
13
+ export const stopQueues = () => getAgenda().drain(30_000);