• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 19408198354

16 Nov 2025 03:56PM UTC coverage: 85.277% (-0.02%) from 85.301%
19408198354

Pull #717

github

web-flow
Merge 154a49a99 into c463a28bf
Pull Request #717: Replace i18next by Fluent

955 of 1138 branches covered (83.92%)

Branch coverage included in aggregate %.

47 of 54 new or added lines in 5 files covered. (87.04%)

2 existing lines in 1 file now uncovered.

10044 of 11760 relevant lines covered (85.41%)

96.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

50.7
/src/controllers/authenticate.js
1
import { randomUUID } from 'node:crypto';
1✔
2
import { promisify } from 'node:util';
1✔
3
import cookie from 'cookie';
1✔
4
import jwt from 'jsonwebtoken';
1✔
5
import randomString from 'random-string';
1✔
6
import nodeFetch from 'node-fetch';
1✔
7
import htmlescape from 'htmlescape';
1✔
8
import httpErrors from 'http-errors';
1✔
9
import nodemailer from 'nodemailer';
1✔
10
import {
1✔
11
  BannedError,
1✔
12
  ReCaptchaError,
1✔
13
  InvalidResetTokenError,
1✔
14
  UserNotFoundError,
1✔
15
} from '../errors/index.js';
1✔
16
import toItemResponse from '../utils/toItemResponse.js';
1✔
17
import toListResponse from '../utils/toListResponse.js';
1✔
18
import { serializeCurrentUser } from '../utils/serialize.js';
1✔
19
import { t } from '../locale.js';
1✔
20

1✔
21
const { BadRequest } = httpErrors;
1✔
22

1✔
23
/**
1✔
24
 * @typedef {import('../schema').UserID} UserID
1✔
25
 */
1✔
26

1✔
27
/**
1✔
28
 * @typedef {object} AuthenticateOptions
1✔
29
 * @prop {string|Buffer} secret
1✔
30
 * @prop {string} [origin]
1✔
31
 * @prop {import('nodemailer').Transport} [mailTransport]
1✔
32
 * @prop {{ secret: string }} [recaptcha]
1✔
33
 * @prop {(options: { token: string, requestUrl: string }) =>
1✔
34
 *   import('nodemailer').SendMailOptions} createPasswordResetEmail
1✔
35
 * @prop {boolean} [cookieSecure]
1✔
36
 * @prop {string} [cookiePath]
1✔
37
 * @typedef {object} WithAuthOptions
1✔
38
 * @prop {AuthenticateOptions} authOptions
1✔
39
 */
1✔
40

1✔
41
/**
1✔
42
 * @type {import('../types.js').Controller}
1✔
43
 */
1✔
44
async function getCurrentUser(req) {
4✔
45
  return toItemResponse(req.user != null ? serializeCurrentUser(req.user) : null, {
4✔
46
    url: req.fullUrl,
4✔
47
  });
4✔
48
}
4✔
49

1✔
50
/**
1✔
51
 * @type {import('../types.js').Controller}
1✔
52
 */
1✔
53
async function getAuthStrategies(req) {
2✔
54
  const { passport } = req.uwave;
2✔
55

2✔
56
  const strategies = passport.strategies();
2✔
57

2✔
58
  return toListResponse(
2✔
59
    strategies,
2✔
60
    { url: req.fullUrl },
2✔
61
  );
2✔
62
}
2✔
63

1✔
64
/**
1✔
65
 * @param {import('../types.js').Request} req
1✔
66
 * @param {import('../schema').User} user
1✔
67
 * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options
1✔
68
 */
1✔
69
async function refreshSession(req, user, options) {
×
70
  const { authRegistry } = req.uwaveHttp;
×
71
  const sessionID = req.authInfo?.sessionID ?? req.sessionID;
×
72

×
73
  const token = jwt.sign(
×
74
    { id: user.id, sessionID: randomUUID() },
×
75
    options.secret,
×
76
    { expiresIn: '31d' },
×
77
  );
×
78

×
79
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
80

×
81
  if (options.session === 'cookie') {
×
82
    return { token: 'cookie', socketToken };
×
83
  }
×
84

×
85
  return { token, socketToken };
×
86
}
×
87

1✔
88
/**
1✔
89
 * The login controller is called once a user has logged in successfully using Passport;
1✔
90
 * we only have to assign the JWT.
1✔
91
 *
1✔
92
 * @typedef {object} LoginQuery
1✔
93
 * @prop {'cookie'|'token'} [session]
1✔
94
 * @param {import('../types.js').AuthenticatedRequest<{}, LoginQuery, {}> & WithAuthOptions} req
1✔
95
 */
1✔
96
async function login(req) {
×
97
  const options = req.authOptions;
×
98
  const { user } = req;
×
99
  const { session } = req.query;
×
100
  const { bans } = req.uwave;
×
101

×
102
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
×
103

×
104
  if (await bans.isBanned(user)) {
×
105
    throw new BannedError();
×
106
  }
×
107

×
108
  const { token, socketToken } = await refreshSession(
×
109
    req,
×
110
    user,
×
111
    { ...options, session: sessionType },
×
112
  );
×
113

×
114
  return toItemResponse(serializeCurrentUser(user), {
×
115
    meta: {
×
116
      jwt: sessionType === 'token' ? token : 'cookie',
×
117
      socketToken,
×
118
    },
×
119
  });
×
120
}
×
121

1✔
122
/**
1✔
123
 * @param {import('../Uwave.js').default} uw
1✔
124
 * @param {import('../schema.js').User} user
1✔
125
 * @param {string} service
1✔
126
 */
1✔
127
async function getSocialAvatar(uw, user, service) {
×
128
  const auth = await uw.db.selectFrom('authServices')
×
129
    .where('userID', '=', user.id)
×
130
    .where('service', '=', service)
×
131
    .select(['serviceAvatar'])
×
132
    .executeTakeFirst();
×
133

×
134
  return auth?.serviceAvatar ?? null;
×
135
}
×
136

1✔
137
/**
1✔
138
 * @param {string} service
1✔
139
 * @param {import('../types.js').AuthenticatedRequest & WithAuthOptions} req
1✔
140
 * @param {import('express').Response} res
1✔
141
 */
1✔
142
async function socialLoginCallback(service, req, res) {
×
143
  const { user } = req;
×
NEW
144
  const { bans } = req.uwave;
×
145
  const { origin } = req.authOptions;
×
146

×
147
  if (await bans.isBanned(user)) {
×
148
    throw new BannedError();
×
149
  }
×
150

×
151
  /**
×
152
   * @type {{ pending: boolean, id?: string, type?: string, avatars?: Record<string, string> }}
×
153
   */
×
154
  let activationData = { pending: false };
×
155
  if (user.pendingActivation) {
×
156
    const socialAvatar = await getSocialAvatar(req.uwave, user, service);
×
157

×
158
    /** @type {Record<string, string>} */
×
159
    const avatars = {
×
160
      sigil: `https://sigil.u-wave.net/${user.id}`,
×
161
    };
×
162
    if (socialAvatar) {
×
163
      avatars[service] = socialAvatar;
×
164
    }
×
165
    activationData = {
×
166
      pending: true,
×
167
      id: user.id,
×
168
      avatars,
×
169
      type: service,
×
170
    };
×
171
  }
×
172

×
173
  const script = `
×
174
    var opener = window.opener;
×
175
    if (opener) {
×
176
      opener.postMessage(${htmlescape(activationData)}, ${htmlescape(origin)});
×
177
    }
×
178
    window.close();
×
179
  `;
×
180

×
181
  await refreshSession(req, user, { ...req.authOptions, session: 'cookie' });
×
182

×
183
  res.end(`
×
184
    <!DOCTYPE html>
×
185
    <html>
×
186
      <head>
×
187
        <meta charset="utf-8">
×
NEW
188
        <title>${t('login-success-title')}</title>
×
189
      </head>
×
190
      <body style="background: #151515; color: #fff; font: 12pt 'Open Sans', sans-serif">
×
NEW
191
        ${t('login-close-this-window')}
×
192
        <script>${script}</script>
×
193
      </body>
×
194
    </html>
×
195
  `);
×
196
}
×
197

1✔
198
/**
1✔
199
 * @typedef {object} SocialLoginFinishQuery
1✔
200
 * @prop {'cookie'|'token'} [session]
1✔
201
 * @typedef {object} SocialLoginFinishBody
1✔
202
 * @prop {string} username
1✔
203
 * @prop {string} avatar
1✔
204
 */
1✔
205

1✔
206
/**
1✔
207
 * @param {string} service
1✔
208
 * @param {import('../types.js').Request<{}, SocialLoginFinishQuery, SocialLoginFinishBody> &
1✔
209
 *         WithAuthOptions} req
1✔
210
 */
1✔
211
async function socialLoginFinish(service, req) {
×
212
  const options = req.authOptions;
×
213
  const { pendingUser: user } = req;
×
214
  const sessionType = req.query.session === 'cookie' ? 'cookie' : 'token';
×
215
  const { db, bans } = req.uwave;
×
216

×
217
  if (!user) {
×
218
    // Should never happen so not putting much effort into
×
219
    // localising the error message.
×
220
    throw new BadRequest('This account has already been set up');
×
221
  }
×
222

×
223
  if (await bans.isBanned(user)) {
×
224
    throw new BannedError();
×
225
  }
×
226

×
227
  const { username, avatar } = req.body;
×
228

×
229
  // TODO Use the avatars plugin for this stuff later.
×
230
  let avatarUrl;
×
231
  if (avatar !== 'sigil') {
×
232
    avatarUrl = await getSocialAvatar(req.uwave, user, service);
×
233
  }
×
234
  if (!avatarUrl) {
×
235
    avatarUrl = `https://sigil.u-wave.net/${user.id}`;
×
236
  }
×
237

×
238
  const updates = await db.updateTable('users')
×
239
    .where('id', '=', user.id)
×
240
    .set({
×
241
      username,
×
242
      avatar: avatarUrl,
×
243
      pendingActivation: false,
×
244
    })
×
245
    .returning(['username', 'avatar', 'pendingActivation'])
×
246
    .executeTakeFirst();
×
247

×
248
  Object.assign(user, updates);
×
249

×
250
  const passportLogin = promisify(
×
251
    /**
×
252
     * @type {(
×
253
     *   user: Express.User,
×
254
     *   options: import('passport').LogInOptions,
×
255
     *   callback: (err: any) => void,
×
256
     * ) => void}
×
257
     */
×
258
    (req.login),
×
259
  );
×
260
  await passportLogin(user, { session: sessionType === 'cookie' });
×
261

×
262
  const { token, socketToken } = await refreshSession(
×
263
    req,
×
264
    user,
×
265
    { ...options, session: sessionType },
×
266
  );
×
267

×
268
  return toItemResponse(user, {
×
269
    meta: {
×
270
      jwt: sessionType === 'token' ? token : 'cookie',
×
271
      socketToken,
×
272
    },
×
273
  });
×
274
}
×
275

1✔
276
/**
1✔
277
 * @type {import('../types.js').AuthenticatedController}
1✔
278
 */
1✔
279
async function getSocketToken(req) {
×
280
  const { user, sessionID } = req;
×
281
  const { authRegistry } = req.uwaveHttp;
×
282

×
283
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
284

×
285
  return toItemResponse({ socketToken }, {
×
286
    url: req.fullUrl,
×
287
  });
×
288
}
×
289

1✔
290
/**
1✔
291
 * @param {string} responseString
1✔
292
 * @param {{ secret: string, logger?: import('pino').Logger }} options
1✔
293
 */
1✔
294
async function verifyCaptcha(responseString, options) {
1✔
295
  options.logger?.info('recaptcha: sending siteverify request');
1✔
296
  const response = await nodeFetch('https://www.google.com/recaptcha/api/siteverify', {
1✔
297
    method: 'post',
1✔
298
    headers: {
1✔
299
      'content-type': 'application/x-www-form-urlencoded',
1✔
300
      accept: 'application/json',
1✔
301
    },
1✔
302
    body: new URLSearchParams({
1✔
303
      response: responseString,
1✔
304
      secret: options.secret,
1✔
305
    }),
1✔
306
  });
1✔
307
  const body = /** @type {{ success: boolean }} */ (await response.json());
1✔
308

1✔
309
  if (!body.success) {
1!
310
    options.logger?.warn(body, 'recaptcha: validation failure');
×
311
    throw new ReCaptchaError();
×
312
  } else {
1✔
313
    options.logger?.info('recaptcha: ok');
1✔
314
  }
1✔
315
}
1✔
316

1✔
317
/**
1✔
318
 * @typedef {object} RegisterBody
1✔
319
 * @prop {string} email
1✔
320
 * @prop {string} username
1✔
321
 * @prop {string} password
1✔
322
 * @prop {string} [grecaptcha]
1✔
323
 */
1✔
324

1✔
325
/**
1✔
326
 * @param {import('../types.js').Request<{}, {}, RegisterBody> & WithAuthOptions} req
1✔
327
 */
1✔
328
async function register(req) {
9✔
329
  const { users } = req.uwave;
9✔
330
  const {
9✔
331
    grecaptcha, email, username, password,
9✔
332
  } = req.body;
9✔
333

9✔
334
  const recaptchaOptions = req.authOptions.recaptcha;
9✔
335
  if (recaptchaOptions && recaptchaOptions.secret) {
9✔
336
    if (grecaptcha) {
2✔
337
      await verifyCaptcha(grecaptcha, {
1✔
338
        secret: recaptchaOptions.secret,
1✔
339
        logger: req.log,
1✔
340
      });
1✔
341
    } else {
1✔
342
      req.log.warn('missing client-side captcha response');
1✔
343
      throw new ReCaptchaError();
1✔
344
    }
1✔
345
  }
2✔
346

8✔
347
  const user = await users.createUser({
8✔
348
    email,
8✔
349
    username,
8✔
350
    password,
8✔
351
  });
8✔
352

6✔
353
  return toItemResponse(serializeCurrentUser(user));
6✔
354
}
9✔
355

1✔
356
/**
1✔
357
 * @typedef {object} RequestPasswordResetBody
1✔
358
 * @prop {string} email
1✔
359
 */
1✔
360

1✔
361
/**
1✔
362
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
1✔
363
 */
1✔
364
async function reset(req) {
2✔
365
  const { db, redis } = req.uwave;
2✔
366
  const { email } = req.body;
2✔
367
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
2✔
368

2✔
369
  const user = await db.selectFrom('users')
2✔
370
    .where('email', '=', email)
2✔
371
    .select(['id'])
2✔
372
    .executeTakeFirst();
2✔
373
  if (!user) {
2!
374
    throw new UserNotFoundError({ email });
×
375
  }
×
376

2✔
377
  const token = randomString({ length: 35, special: false });
2✔
378

2✔
379
  await redis.set(`reset:${token}`, user.id);
2✔
380
  await redis.expire(`reset:${token}`, 24 * 60 * 60);
2✔
381

2✔
382
  const message = createPasswordResetEmail({
2✔
383
    token,
2✔
384
    requestUrl: req.fullUrl,
2✔
385
  });
2✔
386

2✔
387
  const transporter = nodemailer.createTransport(mailTransport ?? {
2!
388
    host: 'localhost',
×
389
    port: 25,
×
390
    debug: true,
×
391
    tls: {
×
392
      rejectUnauthorized: false,
×
393
    },
×
394
  });
2✔
395

2✔
396
  await transporter.sendMail({ to: email, ...message });
2✔
397

2✔
398
  return toItemResponse({});
2✔
399
}
2✔
400

1✔
401
/**
1✔
402
 * @typedef {object} ChangePasswordParams
1✔
403
 * @prop {string} reset
1✔
404
 * @typedef {object} ChangePasswordBody
1✔
405
 * @prop {string} password
1✔
406
 */
1✔
407

1✔
408
/**
1✔
409
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
1✔
410
 */
1✔
411
async function changePassword(req) {
×
412
  const { users, redis } = req.uwave;
×
413
  const { reset: resetToken } = req.params;
×
414
  const { password } = req.body;
×
415

×
416
  const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`));
×
417
  if (!userID) {
×
418
    throw new InvalidResetTokenError();
×
419
  }
×
420

×
421
  const user = await users.getUser(userID);
×
422
  if (!user) {
×
423
    throw new UserNotFoundError({ id: userID });
×
424
  }
×
425

×
426
  await users.updatePassword(user.id, password);
×
427

×
428
  await redis.del(`reset:${resetToken}`);
×
429

×
430
  return toItemResponse({}, {
×
431
    meta: {
×
432
      message: `Updated password for ${user.username}`,
×
433
    },
×
434
  });
×
435
}
×
436

1✔
437
/**
1✔
438
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
1✔
439
 * @param {import('express').Response} res
1✔
440
 */
1✔
441
async function logout(req, res) {
×
442
  const { user, cookies } = req;
×
443
  const { cookieSecure, cookiePath } = req.authOptions;
×
444
  const uw = req.uwave;
×
445

×
446
  uw.publish('user:logout', {
×
447
    userID: user.id,
×
448
  });
×
449

×
450
  // Clear the legacy `uwsession` cookie.
×
451
  if (cookies && cookies.uwsession) {
×
452
    const serialized = cookie.serialize('uwsession', '', {
×
453
      httpOnly: true,
×
454
      secure: !!cookieSecure,
×
455
      path: cookiePath ?? '/',
×
456
      maxAge: 0,
×
457
    });
×
458
    res.setHeader('Set-Cookie', serialized);
×
459
  }
×
460

×
461
  const passportLogout = promisify(req.logout.bind(req));
×
462
  await passportLogout();
×
463

×
464
  return toItemResponse({});
×
465
}
×
466

1✔
467
/**
1✔
468
 * @returns {Promise<import('type-fest').JsonObject>}
1✔
469
 */
1✔
470
async function removeSession() {
×
471
  throw new Error('Unimplemented');
×
472
}
×
473

1✔
474
export {
1✔
475
  changePassword,
1✔
476
  getAuthStrategies,
1✔
477
  getCurrentUser,
1✔
478
  getSocketToken,
1✔
479
  login,
1✔
480
  logout,
1✔
481
  register,
1✔
482
  removeSession,
1✔
483
  reset,
1✔
484
  socialLoginCallback,
1✔
485
  socialLoginFinish,
1✔
486
};
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc