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

u-wave / core / 12200614762

06 Dec 2024 02:32PM UTC coverage: 84.144% (-0.2%) from 84.36%
12200614762

Pull #682

github

goto-bus-stop
Mimick session ID for JWT auth

The main point is to allow the tests to continue to use JWT auth,
while also testing session features such as missing messages for lost
connections
Pull Request #682: Improve session handling

906 of 1085 branches covered (83.5%)

Branch coverage included in aggregate %.

83 of 154 new or added lines in 11 files covered. (53.9%)

6 existing lines in 1 file now uncovered.

9867 of 11718 relevant lines covered (84.2%)

90.35 hits per line

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

50.6
/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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1✔
473
export {
1✔
474
  changePassword,
1✔
475
  getAuthStrategies,
1✔
476
  getCurrentUser,
1✔
477
  getSocketToken,
1✔
478
  login,
1✔
479
  logout,
1✔
480
  register,
1✔
481
  removeSession,
1✔
482
  reset,
1✔
483
  socialLoginCallback,
1✔
484
  socialLoginFinish,
1✔
485
};
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

© 2026 Coveralls, Inc