exp-node-server 1.1.7 → 2.1.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 (183) hide show
  1. package/README.md +162 -20
  2. package/dist/cli.js +613 -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 +4 -0
  7. package/templates/mongoose/.prettierrc +7 -0
  8. package/templates/mongoose/Dockerfile +33 -0
  9. package/templates/mongoose/_gitignore +35 -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 +6366 -0
  13. package/templates/mongoose/package.json +49 -11
  14. package/templates/mongoose/readme.md +88 -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 +36 -0
  44. package/templates/mongoose/src/app/queues/email.queue.ts +81 -0
  45. package/templates/mongoose/src/app/queues/index.ts +17 -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/queues/email.queue.test.ts +137 -0
  68. package/templates/mongoose/tests/routes/account.test.ts +614 -0
  69. package/templates/mongoose/tests/routes/profile.test.ts +95 -0
  70. package/templates/mongoose/tests/routes/social.test.ts +310 -0
  71. package/templates/mongoose/tests/setup.ts +25 -0
  72. package/templates/mongoose/tsconfig.json +16 -20
  73. package/templates/mongoose/vitest.config.ts +43 -0
  74. package/templates/prisma/.dockerignore +2 -0
  75. package/templates/prisma/.env.example +62 -6
  76. package/templates/prisma/.prettierignore +4 -0
  77. package/templates/prisma/.prettierrc +7 -0
  78. package/templates/prisma/.projectrc.json +1 -1
  79. package/templates/prisma/Dockerfile +21 -7
  80. package/templates/prisma/_gitignore +38 -0
  81. package/templates/prisma/docker-compose.yml +8 -7
  82. package/templates/prisma/eslint.config.js +35 -0
  83. package/templates/prisma/package-lock.json +7316 -0
  84. package/templates/prisma/package.json +47 -10
  85. package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
  86. package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
  87. package/templates/prisma/prisma/schema/schema.prisma +1 -2
  88. package/templates/prisma/prisma.config.ts +5 -5
  89. package/templates/prisma/readme.md +90 -0
  90. package/templates/prisma/src/app/configs/index.ts +175 -26
  91. package/templates/prisma/src/app/errors/zodError.ts +16 -16
  92. package/templates/prisma/src/app/lib/logger.ts +12 -0
  93. package/templates/prisma/src/app/lib/prisma.ts +4 -6
  94. package/templates/prisma/src/app/middlewares/auth.ts +37 -16
  95. package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
  96. package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
  97. package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
  98. package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
  99. package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
  100. package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
  101. package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
  102. package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
  103. package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
  104. package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
  105. package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
  106. package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
  107. package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
  108. package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
  109. package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
  110. package/templates/prisma/src/app/passport-js/index.ts +87 -0
  111. package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
  112. package/templates/prisma/src/app/passport-js/providers.ts +263 -0
  113. package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
  114. package/templates/prisma/src/app/queues/boss.ts +33 -0
  115. package/templates/prisma/src/app/queues/email.queue.ts +71 -0
  116. package/templates/prisma/src/app/queues/index.ts +23 -0
  117. package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
  118. package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
  119. package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
  120. package/templates/prisma/src/app/types/error.d.ts +7 -7
  121. package/templates/prisma/src/app/types/express.d.ts +11 -0
  122. package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
  123. package/templates/prisma/src/app/utils/JWT.ts +36 -17
  124. package/templates/prisma/src/app/utils/app_error.ts +10 -10
  125. package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
  126. package/templates/prisma/src/app/utils/catch_async.ts +8 -8
  127. package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
  128. package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
  129. package/templates/prisma/src/app/utils/manage_response.ts +19 -19
  130. package/templates/prisma/src/app/utils/otp.ts +20 -0
  131. package/templates/prisma/src/app/utils/session.ts +57 -0
  132. package/templates/prisma/src/app.ts +73 -42
  133. package/templates/prisma/src/routes.ts +12 -10
  134. package/templates/prisma/src/server.ts +37 -19
  135. package/templates/prisma/src/swaggerOptions.ts +22 -26
  136. package/templates/prisma/tests/app.test.ts +86 -0
  137. package/templates/prisma/tests/helpers/fixtures.ts +35 -0
  138. package/templates/prisma/tests/helpers/prisma.ts +32 -0
  139. package/templates/prisma/tests/queues/email.queue.test.ts +162 -0
  140. package/templates/prisma/tests/routes/account.test.ts +627 -0
  141. package/templates/prisma/tests/routes/profile.test.ts +91 -0
  142. package/templates/prisma/tests/routes/social.test.ts +317 -0
  143. package/templates/prisma/tests/setup.ts +10 -0
  144. package/templates/prisma/tsconfig.json +16 -23
  145. package/templates/prisma/vitest.config.ts +38 -0
  146. package/bin/cli.js +0 -41
  147. package/src/createProject.js +0 -61
  148. package/src/generateModule.js +0 -93
  149. package/src/utils/getProjectType.js +0 -13
  150. package/src/utils/mongoose/controllerGenerator.js +0 -70
  151. package/src/utils/mongoose/routeGenerator.js +0 -27
  152. package/src/utils/mongoose/schemaGenerator.js +0 -11
  153. package/src/utils/mongoose/serviceGenerator.js +0 -49
  154. package/src/utils/mongoose/swaggerGenerator.js +0 -112
  155. package/src/utils/mongoose/validationGenerator.js +0 -13
  156. package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
  157. package/src/utils/prisma/prismaServiceGenerator.js +0 -47
  158. package/src/utils/routeInjector.js +0 -40
  159. package/src/utils/swaggerInjector.js +0 -52
  160. package/src/utils/validateModuleName.js +0 -17
  161. package/src/utils/validateProjectName.js +0 -15
  162. package/src/utils.js +0 -26
  163. package/templates/mongoose/src/app/errors/castError.ts +0 -23
  164. package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
  165. package/templates/mongoose/src/app/errors/validationError.ts +0 -25
  166. package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
  167. package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
  168. package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
  169. package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
  170. package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
  171. package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
  172. package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
  173. package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
  174. package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
  175. package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
  176. package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
  177. package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
  178. package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
  179. package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
  180. package/templates/mongoose/src/app/types/index.d.ts +0 -12
  181. package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
  182. package/templates/prisma/src/app/types/index.d.ts +0 -12
  183. 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,36 @@
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
+ export type AgendaOptions = {
7
+ /** How often a worker looks for due jobs (ms or a human interval). */
8
+ processEvery?: string | number;
9
+ };
10
+
11
+ /**
12
+ * Agenda stores jobs in the app's MongoDB (`agendaJobs` collection), so no Redis
13
+ * is needed. Created on start, once mongoose is connected, to reuse its connection.
14
+ */
15
+ let agenda: Agenda | undefined;
16
+
17
+ /** A new Agenda instance (a worker) on the app's MongoDB connection. */
18
+ export const newAgenda = ({ processEvery = '5 seconds' }: AgendaOptions = {}) => {
19
+ const instance = new Agenda({
20
+ backend: new MongoBackend({ mongo: mongoose.connection.db! }),
21
+ processEvery,
22
+ });
23
+ instance.on('error', (err) => logger.error(err, 'Agenda error'));
24
+ return instance;
25
+ };
26
+
27
+ /** Creates the app's Agenda instance, used by `enqueueEmail()` & co. */
28
+ export const createAgenda = (options?: AgendaOptions) => {
29
+ agenda = newAgenda(options);
30
+ return agenda;
31
+ };
32
+
33
+ export const getAgenda = (): Agenda => {
34
+ if (!agenda) throw new Error('Queues are not started, call startQueues() first');
35
+ return agenda;
36
+ };
@@ -0,0 +1,81 @@
1
+ import { type Agenda, exponential, type Job } from 'agenda';
2
+ import mongoose from 'mongoose';
3
+ import { configs } from '../configs/index.js';
4
+ import { logger } from '../lib/logger.js';
5
+ import { otpEmailTemplate } from '../templates/otpEmailTemplate.js';
6
+ import { resetPasswordEmailTemplate } from '../templates/resetPasswordEmailTemplate.js';
7
+ import sendMail from '../utils/mail_sender.js';
8
+ import { getAgenda } from './agenda.js';
9
+
10
+ export const EMAIL_QUEUE = 'email';
11
+ // Agenda's default collection (see agenda.ts)
12
+ export const JOBS_COLLECTION = 'agendaJobs';
13
+
14
+ /** Every email the app sends goes through this one queue. */
15
+ export type EmailJob =
16
+ | { type: 'verify-account'; email: string; otp: string; verificationLink: string }
17
+ | { type: 'reset-password'; email: string; resetLink: string };
18
+
19
+ const render = (job: EmailJob) => {
20
+ switch (job.type) {
21
+ case 'verify-account':
22
+ return {
23
+ subject: 'Verify Your Email',
24
+ htmlBody: otpEmailTemplate(job),
25
+ textBody: `Your verification code is ${job.otp}. Or verify using this link: ${job.verificationLink}`,
26
+ };
27
+ case 'reset-password':
28
+ return {
29
+ subject: 'Reset Your Password',
30
+ htmlBody: resetPasswordEmailTemplate(job.resetLink, configs.jwt.reset_expires),
31
+ textBody: `Reset your password using this link: ${job.resetLink}`,
32
+ };
33
+ }
34
+ };
35
+
36
+ export type EmailJobOptions = {
37
+ /** Delay before the first retry; it doubles on every further retry. */
38
+ retryDelayMs?: number;
39
+ maxRetries?: number;
40
+ };
41
+
42
+ /** Registers the email job on a worker. */
43
+ export const defineEmailJob = (
44
+ agenda: Agenda,
45
+ { retryDelayMs = 10_000, maxRetries = 3 }: EmailJobOptions = {},
46
+ ) =>
47
+ agenda.define(
48
+ EMAIL_QUEUE,
49
+ async ({ attrs }: Job<EmailJob>) => {
50
+ const data = attrs.data;
51
+ try {
52
+ await sendMail({ to: data.email, ...render(data) });
53
+ logger.info(`Email (${data.type}) sent to ${data.email}`);
54
+ } catch (err) {
55
+ logger.error({ err, jobId: attrs._id }, `Failed to send ${data.type} email, will retry`);
56
+ throw err; // let Agenda retry the job
57
+ }
58
+ },
59
+ {
60
+ backoff: exponential({ delay: retryDelayMs, factor: 2, maxRetries }),
61
+ // Not `removeOnComplete`: Agenda emits "complete" before it forgets the job,
62
+ // and the extra await of the removal makes drain() miss it and hang until its
63
+ // timeout on every shutdown. A TTL index cleans up instead (below).
64
+ },
65
+ );
66
+
67
+ /**
68
+ * Jobs carry OTPs and reset links, so finished email jobs (sent or given up on)
69
+ * are deleted by MongoDB an hour later. Only the email job: other jobs are kept.
70
+ */
71
+ export const ensureEmailJobCleanup = () =>
72
+ mongoose.connection.db!.collection(JOBS_COLLECTION).createIndex(
73
+ { lastFinishedAt: 1 },
74
+ {
75
+ name: 'email_jobs_ttl',
76
+ expireAfterSeconds: 60 * 60,
77
+ partialFilterExpression: { name: EMAIL_QUEUE },
78
+ },
79
+ );
80
+
81
+ export const enqueueEmail = (job: EmailJob) => getAgenda().now(EMAIL_QUEUE, job);
@@ -0,0 +1,17 @@
1
+ import { logger } from '../lib/logger.js';
2
+ import { type AgendaOptions, createAgenda, getAgenda } from './agenda.js';
3
+ import { defineEmailJob, type EmailJobOptions, ensureEmailJobCleanup } from './email.queue.js';
4
+
5
+ /** The defaults are meant for production; tests pass shorter delays. */
6
+ export type QueueOptions = AgendaOptions & EmailJobOptions;
7
+
8
+ export const startQueues = async (options: QueueOptions = {}) => {
9
+ const agenda = createAgenda(options);
10
+ defineEmailJob(agenda, options);
11
+ await ensureEmailJobCleanup();
12
+ await agenda.start();
13
+ logger.info('Queues are running...');
14
+ };
15
+
16
+ // waits for running jobs to finish (up to 30s) instead of abandoning them
17
+ export const stopQueues = () => getAgenda().drain(30_000);