@devmoods/express-extras 0.52.3 → 0.53.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.
@@ -1,6 +1,6 @@
1
1
  import { type AnyUser, type AuthProvider } from './types.js';
2
2
  export interface PasswordlessAuthProviderOptions<UserModel = AnyUser> {
3
3
  getUserByEmail(email: string): Promise<UserModel | null>;
4
- onLoginRequest(user: UserModel, temporaryToken: string): Promise<void>;
4
+ onLoginRequest(user: UserModel, temporaryToken: string, otp: string): Promise<void>;
5
5
  }
6
6
  export declare function PasswordlessAuthProvider<UserModel extends AnyUser>(options: PasswordlessAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
@@ -1,6 +1,5 @@
1
1
  import { createTokenResponse } from './token.js';
2
2
  import {} from './types.js';
3
- import { Unauthorized } from '../errors.js';
4
3
  import { getLogger } from '../logger.js';
5
4
  import { route } from '../middleware.js';
6
5
  import { DoesNotExistError } from '../postgres.js';
@@ -20,19 +19,25 @@ export function PasswordlessAuthProvider(options) {
20
19
  };
21
20
  return {
22
21
  setupRoutes(router, context) {
23
- router.post('/passwordless/login/:token', route(async (req, res) => {
24
- const { token: temporaryToken } = req.params;
25
- const decoded = context.decodeTemporaryToken(temporaryToken);
26
- const user = await context.getUserById(decoded.payload.uid);
27
- try {
28
- await context.verifyTemporaryToken(temporaryToken, user);
29
- }
30
- catch {
31
- logger.warn('passwordless: invalid temp token', { uid: user.id });
32
- throw Unauthorized('Invalid token');
33
- }
22
+ router.post('/passwordless/login', validateBody({
23
+ type: 'object',
24
+ properties: {
25
+ type: { type: 'string', enum: ['otp', 'token'] },
26
+ otp: { type: 'string' },
27
+ token: { type: 'string' },
28
+ },
29
+ required: ['type'],
30
+ }), route(async (req, res) => {
31
+ const { token: temporaryToken, otp, type: tokenType } = req.body;
32
+ const verify = tokenType === 'otp'
33
+ ? context.verifyOtp
34
+ : context.verifyTemporaryToken;
35
+ const tokenValue = tokenType === 'otp' ? otp : temporaryToken;
36
+ const uid = await verify(tokenValue, null, false);
37
+ const user = await context.getUserById(uid);
38
+ await verify(tokenValue, user);
34
39
  const accessToken = await context.login(req, res, user);
35
- logger.info('passwordless login', { uid: user.id });
40
+ logger.info('passwordless login', { tokenType, uid: user.id });
36
41
  if (req.accepts('html')) {
37
42
  return res.redirect('/');
38
43
  }
@@ -55,7 +60,8 @@ export function PasswordlessAuthProvider(options) {
55
60
  return 202;
56
61
  }
57
62
  const token = await context.createTemporaryToken(user);
58
- await options.onLoginRequest(user, token);
63
+ const otp = await context.createOtp(user);
64
+ await options.onLoginRequest(user, token, otp);
59
65
  logger.info('passwordless login requested', { email });
60
66
  return 202;
61
67
  }));
@@ -35,8 +35,10 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
35
35
  tokenStorage: AuthOptions<UserModel>['tokenStorage'];
36
36
  setCurrentUser: (user: UserModel) => void;
37
37
  createTemporaryToken(user: UserModel): Promise<string>;
38
- verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
38
+ verifyTemporaryToken(token: string, user: UserModel | null, consume?: boolean): Promise<string>;
39
39
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
40
+ createOtp(user: UserModel): Promise<string>;
41
+ verifyOtp(otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
40
42
  middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
41
43
  login: (req: Request, res: Response, user: UserModel, isRefresh?: boolean) => Promise<AccessToken>;
42
44
  getUserById(id: string): Promise<UserModel>;
package/dist/auth.d.ts CHANGED
@@ -12,8 +12,10 @@ export interface CreateAuth<UserModel extends AnyUser> {
12
12
  before: () => RequestHandler;
13
13
  isAuthorized: (fn?: (user: UserModel) => boolean) => RequestHandler;
14
14
  createTemporaryToken(user: UserModel): Promise<string>;
15
- verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
15
+ verifyTemporaryToken(token: string, user: UserModel | null): Promise<string>;
16
16
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
17
+ createOtp(user: UserModel): Promise<string>;
18
+ verifyOtp(otp: string, user: UserModel | null): Promise<string>;
17
19
  logout: (req: Request, res: Response) => Promise<void>;
18
20
  }
19
21
  export declare class RedisTokenStorage extends TokenStorage {
package/dist/auth.js CHANGED
@@ -71,6 +71,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
71
71
  createTemporaryToken,
72
72
  verifyTemporaryToken,
73
73
  decodeTemporaryToken,
74
+ createOtp,
75
+ verifyOtp,
74
76
  middleware,
75
77
  login,
76
78
  getUserById,
@@ -185,6 +187,24 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
185
187
  }
186
188
  };
187
189
  }
190
+ async function createOtp(user) {
191
+ const otp = crypto.randomInt(100000, 999999).toString();
192
+ await tokenStorage.set(otp, String(user.id), config.value.AUTH_OTP_EXPIRES_IN);
193
+ return otp;
194
+ }
195
+ async function verifyOtp(otp, user, consume = true) {
196
+ const storedUid = await tokenStorage.get(otp);
197
+ if (!storedUid) {
198
+ throw new InvalidToken('Invalid OTP');
199
+ }
200
+ if (user && storedUid !== user.id) {
201
+ throw new InvalidToken('Invalid OTP');
202
+ }
203
+ if (consume) {
204
+ await tokenStorage.invalidate(otp);
205
+ }
206
+ return storedUid;
207
+ }
188
208
  /**
189
209
  * Create a short-lived token tied to a user that can be
190
210
  * used for different kinds of verification.
@@ -213,7 +233,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
213
233
  function decodeTemporaryToken(token) {
214
234
  return JSON.parse(Buffer.from(token, 'base64url').toString('utf-8'));
215
235
  }
216
- async function verifyTemporaryToken(token, user) {
236
+ async function verifyTemporaryToken(token, user, consume = true) {
217
237
  const decoded = decodeTemporaryToken(token);
218
238
  const expectedSignature = crypto
219
239
  .createHmac('sha256', config.value.AUTH_SECRET)
@@ -223,14 +243,20 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
223
243
  if (expectedSignature !== decoded.signature) {
224
244
  throw new InvalidToken('Invalid signature');
225
245
  }
226
- if (decoded.payload.uid !== user.id) {
246
+ if (user && decoded.payload.uid !== user.id) {
227
247
  throw new InvalidToken(`Invalid user ${user.id}`);
228
248
  }
229
249
  const storedUid = await tokenStorage.get(token);
230
- if (storedUid !== user.id) {
250
+ if (!storedUid) {
251
+ throw new InvalidToken('Invalid token not found');
252
+ }
253
+ if (user && storedUid !== user.id) {
231
254
  throw new InvalidToken(`Invalid user ${user.id}`);
232
255
  }
233
- await tokenStorage.invalidate(token);
256
+ if (consume) {
257
+ await tokenStorage.invalidate(token);
258
+ }
259
+ return storedUid;
234
260
  }
235
261
  return {
236
262
  before: () => middleware(false),
@@ -241,6 +267,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
241
267
  createTemporaryToken,
242
268
  verifyTemporaryToken,
243
269
  decodeTemporaryToken,
270
+ createOtp,
271
+ verifyOtp,
244
272
  logout,
245
273
  };
246
274
  }
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const config: {
5
5
  PUBLIC_URL: string;
6
6
  AUTH_TOKEN_EXPIRES_IN: number;
7
7
  AUTH_TEMP_TOKEN_EXPIRES_IN: number;
8
+ AUTH_OTP_EXPIRES_IN: number;
8
9
  AUTH_SECRET: string;
9
10
  AUTH_COOKIE_SAME_SITE: string;
10
11
  REDIS_URL: string;
@@ -37,6 +38,7 @@ export declare function extendConfig<T extends Record<string, unknown>>(appConfi
37
38
  PUBLIC_URL: string;
38
39
  AUTH_TOKEN_EXPIRES_IN: number;
39
40
  AUTH_TEMP_TOKEN_EXPIRES_IN: number;
41
+ AUTH_OTP_EXPIRES_IN: number;
40
42
  AUTH_SECRET: string;
41
43
  AUTH_COOKIE_SAME_SITE: string;
42
44
  REDIS_URL: string;
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ const baseConfig = {
8
8
  PUBLIC_URL: readEnv('PUBLIC_URL', ''),
9
9
  AUTH_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 24 * 30), // 30 days
10
10
  AUTH_TEMP_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600),
11
+ AUTH_OTP_EXPIRES_IN: readEnv.number('AUTH_OTP_EXPIRES', 5 * 60),
11
12
  AUTH_SECRET: readEnv('AUTH_JWT_SECRET', ''),
12
13
  AUTH_COOKIE_SAME_SITE: readEnv('AUTH_COOKIE_SAME_SITE', 'strict'),
13
14
  REDIS_URL: readEnv('REDIS_URL', ''),
package/dist/errors.js CHANGED
@@ -2,6 +2,7 @@ import { STATUS_CODES } from 'node:http';
2
2
  import * as Sentry from '@sentry/node';
3
3
  import { errors } from 'compose-middleware';
4
4
  import {} from 'express';
5
+ import { InvalidToken } from './auth/errors.js';
5
6
  import { getLogger } from './logger.js';
6
7
  import { DoesNotExistError, IntegrityError } from './postgres.js';
7
8
  export class ApiError extends Error {
@@ -48,6 +49,9 @@ function convertToAPIError(error) {
48
49
  if (error instanceof DoesNotExistError) {
49
50
  return NotFound(error.message);
50
51
  }
52
+ if (error instanceof InvalidToken) {
53
+ return Unauthorized(error.message);
54
+ }
51
55
  return error;
52
56
  }
53
57
  function _errorHandler({ onError }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.52.3",
3
+ "version": "0.53.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,31 +46,31 @@
46
46
  "devDependencies": {
47
47
  "@devmoods/eslint-config": "^2.6.0",
48
48
  "@types/koa-compose": "^3.2.8",
49
- "@types/node": "22.13.4",
49
+ "@types/node": "22.13.5",
50
50
  "@types/supertest": "^6.0.2",
51
51
  "@vitejs/plugin-react": "^4.3.4",
52
- "@vitest/coverage-v8": "^3.0.5",
53
- "arktype": "^2.0.4",
52
+ "@vitest/coverage-v8": "^3.0.7",
53
+ "arktype": "^2.1.0",
54
54
  "cookiejar": "^2.1.4",
55
- "eslint": "^9.20.1",
55
+ "eslint": "^9.21.0",
56
56
  "faktory-worker": "^4.6.0",
57
- "prettier": "3.5.1",
57
+ "prettier": "3.5.2",
58
58
  "supertest": "7.0.0",
59
59
  "ts-node": "10.9.2",
60
- "tsx": "^4.19.2",
60
+ "tsx": "^4.19.3",
61
61
  "typescript": "5.7.3",
62
- "vite": "^6.1.0",
63
- "vitest": "^3.0.5",
62
+ "vite": "^6.1.1",
63
+ "vitest": "^3.0.7",
64
64
  "vitest-fail-on-console": "^0.7.1"
65
65
  },
66
66
  "dependencies": {
67
67
  "@devmoods/fetch": "^4.0.1",
68
- "@sentry/node": "^9.1.0",
69
- "@sentry/profiling-node": "^9.1.0",
68
+ "@sentry/node": "^9.2.0",
69
+ "@sentry/profiling-node": "^9.2.0",
70
70
  "@types/bcrypt": "^5.0.2",
71
71
  "@types/cookie-parser": "^1.4.8",
72
72
  "@types/express": "^5.0.0",
73
- "@types/jsonwebtoken": "^9.0.8",
73
+ "@types/jsonwebtoken": "^9.0.9",
74
74
  "@types/nodemailer": "^6.4.17",
75
75
  "@types/pg": "^8.11.11",
76
76
  "@types/superagent": "^8.1.9",
@@ -90,8 +90,8 @@
90
90
  "pg": "^8.13.3",
91
91
  "redis": "^4.7.0",
92
92
  "sonda": "^0.7.1",
93
- "sql-formatter": "^15.4.10",
94
- "uuid": "^11.0.5"
93
+ "sql-formatter": "^15.4.11",
94
+ "uuid": "^11.1.0"
95
95
  },
96
96
  "prettier": {
97
97
  "singleQuote": true