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

cofacts / rumors-api / 25278710249

03 May 2026 12:00PM UTC coverage: 82.226% (+1.2%) from 80.99%
25278710249

Pull #386

github

nonumpa
test(auth): update BFF tests for session isolation; add provider route coverage

- loginRouter tests now assert ctx.state.bffInfo (not ctx.session.redirectTo)
  and verify ctx.session is not touched for BFF flow.
- authRouter tests drive BFF flow via query.state (encoded BFF state) and
  user.id, replacing the old session.redirectTo / user.userId approach.
- 'clears session fields' test replaced with 'does not mutate legacy session
  fields during BFF redirect', asserting full isolation.
- New describe block: assert facebook/github/google provider routes pass the
  encoded composite state to passport.authenticate() when ctx.state.bffInfo
  is set, and omit state option in the legacy flow.
Pull Request #386: feat(auth): Custom Authorization Code Flow with RS256 JWT + JWKS

891 of 1161 branches covered (76.74%)

Branch coverage included in aggregate %.

123 of 132 new or added lines in 7 files covered. (93.18%)

10 existing lines in 1 file now uncovered.

1732 of 2029 relevant lines covered (85.36%)

17.2 hits per line

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

64.65
/src/auth.js
1
import passport from 'koa-passport';
2
import client, { processMeta, getTotalCount } from 'util/client';
3
import FacebookStrategy from 'passport-facebook';
4
import TwitterStrategy from 'passport-twitter';
5
import GithubStrategy from 'passport-github2';
6
import GoogleStrategy from 'passport-google-oauth20';
7
import InstagramStrategy from 'passport-instagram-graph';
8
import Router from 'koa-router';
9
import { signShortLivedJWT } from './lib/jwt';
10

11
// BFF (Backend-for-Frontend) OAuth: lets multiple frontends (cofacts.ai,
12
// rumors-site) share one API-side OAuth registration. Redirect info is
13
// encoded into the OAuth `state` param as base64url(JSON({r, s})), so flows
14
// from different frontends do not clobber each other in koa:sess. Login CSRF
15
// is mitigated by `r` having to pass isAllowedCallbackUrl().
16
function encodeBffOAuthState(bffInfo) {
17
  return Buffer.from(JSON.stringify({ r: bffInfo.r, s: bffInfo.s })).toString(
3✔
18
    'base64url'
19
  );
20
}
21

22
function decodeBffStateFromQuery(ctx) {
23
  const state = ctx.query.state;
5✔
24
  if (!state) return null;
5✔
25
  try {
4✔
26
    const decoded = JSON.parse(
4✔
27
      Buffer.from(state, 'base64url').toString('utf-8')
28
    );
29
    if (typeof decoded.r === 'string' && isAllowedCallbackUrl(decoded.r)) {
4!
30
      return decoded;
4✔
31
    }
32
  } catch {
33
    // malformed state → fall through to legacy session-based flow
34
  }
NEW
35
  return null;
×
36
}
37

38
function bffLoginMiddleware(ctx) {
39
  if (!isAllowedCallbackUrl(ctx.query.redirect_to)) {
5✔
40
    const err = new Error('`redirect_to` is not in the allowed callback URLs');
2✔
41
    err.status = 400;
2✔
42
    err.expose = true;
2✔
43
    throw err;
2✔
44
  }
45
  ctx.state.bffInfo = {
3✔
46
    r: ctx.query.redirect_to,
47
    s: ctx.query.state || '',
4✔
48
  };
49
}
50

51
const bffAwareAuthenticate = (strategy, options) => (ctx, next) => {
9✔
52
  const bffInfo = ctx.state.bffInfo;
6✔
53
  const finalOptions = bffInfo
6✔
54
    ? { ...options, state: encodeBffOAuthState(bffInfo) }
55
    : options;
56
  return passport.authenticate(strategy, finalOptions)(ctx, next);
6✔
57
};
58

59
const getAllowedCallbackUrls = () =>
3✔
60
  (process.env.ALLOWED_CALLBACK_URLS || '')
9!
61
    .split(',')
62
    .map((u) => u.trim())
18✔
63
    .filter(Boolean);
64

65
const isAllowedCallbackUrl = (url) => {
3✔
66
  if (getAllowedCallbackUrls().includes(url)) return true;
9✔
67
  const pattern = process.env.ALLOWED_CALLBACK_PATTERN;
3✔
68
  if (pattern) return new RegExp(`^${pattern}$`).test(url);
3✔
69
  return false;
1✔
70
};
71

72
/**
73
 * Serialize to session
74
 */
75
passport.serializeUser((user, done) => {
3✔
76
  done(null, user.id);
×
77
});
78

79
/**
80
 * De-serialize and populates ctx.state.user
81
 */
82
passport.deserializeUser((userId, done) => {
3✔
83
  try {
×
84
    done(null, { userId });
×
85
  } catch (err) {
86
    done(err);
×
87
  }
88
});
89

90
/**
91
 * Common verify callback for all login strategies.
92
 * It tries first authenticating user with profile.id agains fieldName in DB.
93
 * If not applicable, search for existing users with their email.
94
 * If still not applicable, create a user with currently given profile.
95
 *
96
 * @param {object} profile - passport profile object
97
 * @param {'facebookId'|'githubId'|'twitterId'|'googleId'|'instagramId'} fieldName - The elasticsearch ID field name in user document
98
 */
99
export async function verifyProfile(profile, fieldName) {
100
  // Find user with such user id
101
  //
102
  const users = await client.search({
3✔
103
    index: 'users',
104
    q: `${fieldName}:${profile.id}`,
105
  });
106

107
  if (getTotalCount(users.hits.total)) {
3✔
108
    return processMeta(users.hits.hits[0]);
1✔
109
  }
110

111
  const now = new Date().toISOString();
2✔
112
  const email = profile.emails && profile.emails[0].value;
2✔
113
  const avatar = profile.photos && profile.photos[0].value;
2✔
114
  const username = profile.displayName ? profile.displayName : profile.username;
2✔
115

116
  // Find user with such email
117
  //
118
  if (email) {
2!
119
    const usersWithEmail = await client.search({
2✔
120
      index: 'users',
121
      q: `email:${email}`,
122
    });
123

124
    if (getTotalCount(usersWithEmail.hits.total)) {
2✔
125
      const id = usersWithEmail.hits.hits[0]._id;
1✔
126
      // Fill in fieldName with profile.id so that it does not matter if user's
127
      // email gets changed in the future.
128
      //
129
      const updateUserResult = await client.update({
1✔
130
        index: 'users',
131
        id,
132
        doc: {
133
          [fieldName]: profile.id,
134
          updatedAt: now,
135
        },
136
        _source: true,
137
      });
138

139
      if (updateUserResult.result === 'updated') {
1!
140
        return processMeta({ ...updateUserResult.get, _id: id });
1✔
141
      }
142

143
      throw new Error(updateUserResult.result);
×
144
    }
145
  }
146

147
  // No user in DB, create one
148
  //
149
  const createUserResult = await client.index({
1✔
150
    index: 'users',
151
    document: {
152
      email,
153
      name: username,
154
      avatarUrl: avatar,
155
      [fieldName]: profile.id,
156
      createdAt: now,
157
      updatedAt: now,
158
    },
159
  });
160
  if (createUserResult.result === 'created') {
1!
161
    return processMeta(
1✔
162
      await client.get({
163
        index: 'users',
164
        id: createUserResult._id,
165
      })
166
    );
167
  }
168

169
  throw new Error(createUserResult.result);
×
170
}
171

172
if (process.env.FACEBOOK_APP_ID) {
3!
173
  passport.use(
×
174
    new FacebookStrategy(
175
      {
176
        clientID: process.env.FACEBOOK_APP_ID,
177
        clientSecret: process.env.FACEBOOK_SECRET,
178
        callbackURL: process.env.FACEBOOK_CALLBACK_URL,
179
        profileFields: ['id', 'displayName', 'photos', 'email'],
180
        graphAPIVersion: 'v19.0',
181
      },
182
      (token, tokenSecret, profile, done) =>
183
        verifyProfile(profile, 'facebookId')
×
184
          .then((user) => done(null, user))
×
185
          .catch(done)
186
    )
187
  );
188
}
189

190
if (process.env.TWITTER_CONSUMER_KEY) {
3!
191
  passport.use(
×
192
    new TwitterStrategy(
193
      {
194
        consumerKey: process.env.TWITTER_CONSUMER_KEY,
195
        consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
196
        callbackURL: process.env.TWITTER_CALLBACK_URL,
197

198
        // https://github.com/jaredhanson/passport-twitter/issues/67#issuecomment-275288663
199
        userProfileURL:
200
          'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true',
201
      },
202
      (token, tokenSecret, profile, done) =>
203
        verifyProfile(profile, 'twitterId')
×
204
          .then((user) => done(null, user))
×
205
          .catch(done)
206
    )
207
  );
208
}
209

210
if (process.env.GITHUB_CLIENT_ID) {
3!
211
  passport.use(
×
212
    new GithubStrategy(
213
      {
214
        clientID: process.env.GITHUB_CLIENT_ID,
215
        clientSecret: process.env.GITHUB_SECRET,
216
        callbackURL: process.env.GITHUB_CALLBACK_URL,
217
      },
218
      (token, tokenSecret, profile, done) =>
219
        verifyProfile(profile, 'githubId')
×
220
          .then((user) => done(null, user))
×
221
          .catch(done)
222
    )
223
  );
224
}
225

226
if (process.env.GOOGLE_CLIENT_ID) {
3!
227
  passport.use(
×
228
    new GoogleStrategy(
229
      {
230
        clientID: process.env.GOOGLE_CLIENT_ID,
231
        clientSecret: process.env.GOOGLE_SECRET,
232
        callbackURL: process.env.GOOGLE_CALLBACK_URL,
233
      },
234
      (token, tokenSecret, profile, done) =>
235
        verifyProfile(profile, 'googleId')
×
236
          .then((user) => done(null, user))
×
237
          .catch(done)
238
    )
239
  );
240
}
241

242
// Note: Instagram Basic Display API doesn't provide email
243
// and it needs the `user_media` scope to get profile photo.
244
if (process.env.INSTAGRAM_CLIENT_ID) {
3!
245
  passport.use(
×
246
    new InstagramStrategy(
247
      {
248
        clientID: process.env.INSTAGRAM_CLIENT_ID,
249
        clientSecret: process.env.INSTAGRAM_SECRET,
250
        callbackURL: process.env.INSTAGRAM_CALLBACK_URL,
251
      },
252
      (token, tokenSecret, profile, done) =>
253
        verifyProfile(profile, 'instagramId')
×
254
          .then((user) => done(null, user))
×
255
          .catch(done)
256
    )
257
  );
258
}
259

260
// Exports route handlers
261

262
function legacyLoginMiddleware(ctx) {
263
  if (!ctx.query.redirect.startsWith('/')) {
1!
NEW
264
    const err = new Error(
×
265
      '`redirect` must present in query string and start with `/`'
266
    );
NEW
267
    err.status = 400;
×
NEW
268
    err.expose = true;
×
NEW
269
    throw err;
×
270
  }
271
  ctx.session.appId = ctx.query.appId || 'RUMORS_SITE';
1!
272
  ctx.session.redirect = ctx.query.redirect;
1✔
273
  const referer = ctx.get('Referer');
1✔
274
  if (referer) {
1!
275
    try {
1✔
276
      ctx.session.origin = new URL(referer).origin;
1✔
277
    } catch (e) {
278
      // Ignore invalid Referer
279
    }
280
  }
281
}
282

283
export const loginRouter = Router()
3✔
284
  .use((ctx, next) => {
285
    if (ctx.query.redirect_to) {
7✔
286
      bffLoginMiddleware(ctx);
5✔
287
    } else if (ctx.query.redirect) {
2✔
288
      legacyLoginMiddleware(ctx);
1✔
289
    } else {
290
      const err = new Error(
1✔
291
        'Either `redirect_to` (BFF flow) or `redirect` (legacy, must start with `/`) is required'
292
      );
293
      err.status = 400;
1✔
294
      err.expose = true;
1✔
295
      throw err;
1✔
296
    }
297
    return next();
4✔
298
  })
299
  .get('/facebook', bffAwareAuthenticate('facebook', { scope: ['email'] }))
300
  .get('/twitter', passport.authenticate('twitter'))
301
  .get('/github', bffAwareAuthenticate('github', { scope: ['user:email'] }))
302
  .get('/google', bffAwareAuthenticate('google', { scope: ['profile email'] }))
303
  .get(
304
    '/instagram',
305
    passport.authenticate('instagram', { scope: ['user_profile'] })
306
  );
307

308
const handlePassportCallback = (strategy) => (ctx, next) =>
15✔
309
  passport.authenticate(strategy, (err, user) => {
×
310
    if (!err && !user) err = new Error('No such user');
×
311

312
    if (err) {
×
313
      err.status = 401;
×
314
      throw err;
×
315
    }
316

317
    // BFF flow: do not write passport.user into the legacy koa:sess cookie.
318
    // The OAuth state carries all redirect info, so session is not needed.
NEW
319
    const isBffFlow = Boolean(ctx.state.bffStateFromQuery);
×
NEW
320
    return ctx.login(user, { session: !isBffFlow });
×
321
  })(ctx, next);
322

323
export const authRouter = Router()
3✔
324
  .use(async (ctx, next) => {
325
    const bffStateFromQuery = decodeBffStateFromQuery(ctx);
5✔
326
    ctx.state.bffStateFromQuery = bffStateFromQuery;
5✔
327

328
    if (bffStateFromQuery) {
5✔
329
      await next();
4✔
330
      const userId = ctx.state.user?.id;
4✔
331
      if (!userId) {
4✔
332
        const err = new Error(
1✔
333
          'Authenticated user has no id; cannot mint authorization code'
334
        );
335
        err.status = 401;
1✔
336
        err.expose = true;
1✔
337
        throw err;
1✔
338
      }
339
      const code = await signShortLivedJWT(userId);
3✔
340
      const redirectUrl = new URL(bffStateFromQuery.r);
3✔
341
      redirectUrl.searchParams.set('code', code);
3✔
342
      if (bffStateFromQuery.s) {
3!
343
        redirectUrl.searchParams.set('state', bffStateFromQuery.s);
3✔
344
      }
345
      ctx.redirect(redirectUrl.href);
3✔
346
      return;
3✔
347
    }
348

349
    if (
1!
350
      (!ctx.session.redirect || !ctx.session.appId) &&
2!
351
      !ctx.session.redirectTo
352
    ) {
353
      const err = new Error(
1✔
354
        '`appId` and `redirect` must be set before. Did you forget to go to /login/*?'
355
      );
356
      err.status = 400;
1✔
357
      err.expose = true;
1✔
358
      throw err;
1✔
359
    }
360

361
    await next();
×
362

UNCOV
363
    let basePath = '';
×
UNCOV
364
    if (
×
365
      ctx.session.appId === 'RUMORS_SITE' ||
×
366
      ctx.session.appId === 'DEVELOPMENT_FRONTEND'
367
    ) {
UNCOV
368
      const validOrigins = (
×
369
        process.env.RUMORS_SITE_REDIRECT_ORIGIN || ''
×
370
      ).split(',');
371

UNCOV
372
      basePath =
×
UNCOV
373
        validOrigins.find((o) => o === ctx.session.origin) || validOrigins[0];
×
374
    }
375

376
    // TODO: Get basePath from DB for other client apps
377
    try {
×
378
      ctx.redirect(new URL(ctx.session.redirect, basePath).href);
×
379
    } catch (err) {
UNCOV
380
      err.status = 400;
×
UNCOV
381
      err.expose = true;
×
UNCOV
382
      throw err;
×
383
    }
384

385
    // eslint-disable-next-line require-atomic-updates
UNCOV
386
    ctx.session.appId = undefined;
×
387
    // eslint-disable-next-line require-atomic-updates
UNCOV
388
    ctx.session.redirect = undefined;
×
389
  })
390
  .get('/facebook', handlePassportCallback('facebook'))
391
  .get('/twitter', handlePassportCallback('twitter'))
392
  .get('/github', handlePassportCallback('github'))
393
  .get('/google', handlePassportCallback('google'))
394
  .get('/instagram', handlePassportCallback('instagram'));
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