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

u-wave / core / 30347854963

28 Jul 2026 09:44AM UTC coverage: 68.424%. Remained the same
30347854963

push

github

web-flow
deps: bump cookie from 1.1.1 to 2.0.1 (#791)

Co-authored-by: Renée Kooi <renee@kooi.me>
Signed-off-by: dependabot[bot] <support@github.com>

686 of 1171 branches covered (58.58%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

2027 of 2794 relevant lines covered (72.55%)

174.45 hits per line

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

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

22
const { BadRequest } = httpErrors;
14✔
23

24
/**
25
 * @typedef {import('../schema.js').UserID} UserID
26
 * @typedef {import('../schema.js').PasswordResetToken} PasswordResetToken
27
 */
28

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

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

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

58
  const strategies = passport.strategies();
2✔
59

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

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

75
  const token = jwt.sign(
3✔
76
    { id: user.id, sessionID: randomUUID() },
77
    options.secret,
78
    { expiresIn: '31d' },
79
  );
80

81
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
3✔
82

83
  if (options.session === 'cookie') {
3!
84
    return { token: 'cookie', socketToken };
×
85
  }
86

87
  return { token, socketToken };
3✔
88
}
89

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

104
  const sessionType = session === 'cookie' ? 'cookie' : 'token';
3!
105

106
  if (await bans.isBanned(user)) {
3!
107
    throw new BannedError();
×
108
  }
109

110
  const { token, socketToken } = await refreshSession(
3✔
111
    req,
112
    user,
113
    { ...options, session: sessionType },
114
  );
115

116
  return toItemResponse(serializeCurrentUser(user), {
3✔
117
    meta: {
118
      jwt: sessionType === 'token' ? token : 'cookie',
3!
119
      socketToken,
120
    },
121
  });
122
}
123

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

136
  return auth?.serviceAvatar ?? null;
×
137
}
138

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

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

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

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

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

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

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

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

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

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

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

229
  const { username, avatar } = req.body;
×
230

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

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

250
  Object.assign(user, updates);
×
251

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

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

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

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

285
  const socketToken = await authRegistry.createAuthToken(user, sessionID);
×
286

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

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

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

319
/**
320
 * @typedef {object} RegisterBody
321
 * @prop {string} email
322
 * @prop {string} username
323
 * @prop {string} password
324
 * @prop {string} [grecaptcha]
325
 */
326

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

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

349
  const user = await users.createUser({
11✔
350
    email,
351
    username,
352
    password,
353
  });
354

355
  return toItemResponse(serializeCurrentUser(user));
9✔
356
}
357

358
/**
359
 * @typedef {object} RequestPasswordResetBody
360
 * @prop {string} email
361
 */
362

363
function generateResetToken() {
364
  return /** @type {PasswordResetToken} */ (randomString({ length: 35, special: false }));
3✔
365
}
366

367
const LOCAL_SMTP_TRANSPORT = {
14✔
368
  host: 'localhost',
369
  port: 25,
370
  debug: true,
371
  tls: {
372
    rejectUnauthorized: false,
373
  },
374
};
375

376
/**
377
 * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req
378
 */
379
async function reset(req) {
380
  const { db } = req.uwave;
3✔
381
  const { email } = req.body;
3✔
382
  const { mailTransport, createPasswordResetEmail } = req.authOptions;
3✔
383

384
  const user = await db.selectFrom('users')
3✔
385
    .where('email', '=', email)
386
    .select(['id'])
387
    .executeTakeFirst();
388
  if (!user) {
3!
389
    throw new UserNotFoundError({ email });
×
390
  }
391

392
  const token = generateResetToken();
3✔
393

394
  await db.insertInto('passwordResets')
3✔
395
    .values({ userID: user.id, token })
396
    .execute();
397

398
  const message = createPasswordResetEmail({
3✔
399
    token,
400
    requestUrl: req.fullUrl,
401
  });
402

403
  const transporter = nodemailer.createTransport(mailTransport ?? LOCAL_SMTP_TRANSPORT);
3!
404

405
  await transporter.sendMail({ to: email, ...message });
3✔
406

407
  return toItemResponse({});
3✔
408
}
409

410
/**
411
 * @typedef {object} ChangePasswordParams
412
 * @prop {string} reset
413
 * @typedef {object} ChangePasswordBody
414
 * @prop {string} password
415
 */
416

417
/**
418
 * @type {import('../types.js').Controller<ChangePasswordParams, {}, ChangePasswordBody>}
419
 */
420
async function changePassword(req) {
421
  const { users, db } = req.uwave;
2✔
422
  const { reset: resetToken } = req.params;
2✔
423
  const { password } = req.body;
2✔
424

425
  const expirationTime = subHours(new Date(), 2);
2✔
426

427
  return db.transaction().execute(async (tx) => {
2✔
428
    // Delete in a transaction, so it's rolled back automatically
429
    // if we hit one of the error cases below.
430
    const result = await tx.deleteFrom('passwordResets')
2✔
431
      .returning(['userID'])
432
      .where('token', '=', /** @type {PasswordResetToken} */ (resetToken))
433
      .where('createdAt', '>', expirationTime)
434
      .executeTakeFirst();
435

436
    if (!result) {
2✔
437
      throw new InvalidResetTokenError();
1✔
438
    }
439

440
    const user = await users.getUser(result.userID, tx);
1✔
441
    if (!user) {
1!
442
      throw new UserNotFoundError({ id: result.userID });
×
443
    }
444

445
    await users.updatePassword(user.id, password, tx);
1✔
446

447
    return toItemResponse({}, {
1✔
448
      meta: {
449
        message: `Updated password for ${user.username}`,
450
      },
451
    });
452
  });
453
}
454

455
/**
456
 * @param {import('../types.js').AuthenticatedRequest<{}, {}, {}> & WithAuthOptions} req
457
 * @param {import('express').Response} res
458
 */
459
async function logout(req, res) {
460
  const { user, cookies } = req;
×
461
  const { cookieSecure, cookiePath } = req.authOptions;
×
462
  const uw = req.uwave;
×
463

464
  uw.publish('user:logout', {
×
465
    userID: user.id,
466
  });
467

468
  // Clear the legacy `uwsession` cookie.
469
  if (cookies && cookies.uwsession) {
×
NEW
470
    const serialized = stringifySetCookie({
×
471
      name: 'uwsession',
472
      value: '',
473
      httpOnly: true,
474
      secure: !!cookieSecure,
475
      path: cookiePath ?? '/',
×
476
      maxAge: 0,
477
    });
478
    res.setHeader('Set-Cookie', serialized);
×
479
  }
480

481
  const passportLogout = promisify(req.logout.bind(req));
×
482
  await passportLogout();
×
483

484
  return toItemResponse({});
×
485
}
486

487
/**
488
 * @returns {Promise<import('type-fest').JsonObject>}
489
 */
490
async function removeSession() {
491
  throw new Error('Unimplemented');
×
492
}
493

494
export {
495
  changePassword,
496
  getAuthStrategies,
497
  getCurrentUser,
498
  getSocketToken,
499
  login,
500
  logout,
501
  register,
502
  removeSession,
503
  reset,
504
  socialLoginCallback,
505
  socialLoginFinish,
506
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc