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

cofacts / rumors-api / 25273120288

03 May 2026 07:28AM UTC coverage: 82.251% (+1.3%) from 80.99%
25273120288

Pull #386

github

nonumpa
fix(auth): clear opposite flow's session fields on login

When entering BFF flow, clear legacy fields (redirect, origin); when
entering legacy flow, clear BFF fields (redirectTo, state). Otherwise
a stale redirectTo from an interrupted BFF login (e.g. user cancelled
OAuth consent) would cause subsequent legacy logins to be misrouted
into the BFF callback path.
Pull Request #386: feat(auth): Custom Authorization Code Flow with RS256 JWT + JWKS

884 of 1153 branches covered (76.67%)

Branch coverage included in aggregate %.

115 of 133 new or added lines in 7 files covered. (86.47%)

21 existing lines in 1 file now uncovered.

1725 of 2019 relevant lines covered (85.44%)

17.25 hits per line

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

63.45
/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
const getAllowedCallbackUrls = () =>
3✔
12
  (process.env.ALLOWED_CALLBACK_URLS || '')
5!
13
    .split(',')
14
    .map((u) => u.trim())
10✔
15
    .filter(Boolean);
16

17
const isAllowedCallbackUrl = (url) => {
3✔
18
  if (getAllowedCallbackUrls().includes(url)) return true;
5✔
19
  const pattern = process.env.ALLOWED_CALLBACK_PATTERN;
3✔
20
  if (pattern) return new RegExp(`^${pattern}$`).test(url);
3✔
21
  return false;
1✔
22
};
23

24
/**
25
 * Serialize to session
26
 */
27
passport.serializeUser((user, done) => {
3✔
28
  done(null, user.id);
×
29
});
30

31
/**
32
 * De-serialize and populates ctx.state.user
33
 */
34
passport.deserializeUser((userId, done) => {
3✔
35
  try {
×
36
    done(null, { userId });
×
37
  } catch (err) {
38
    done(err);
×
39
  }
40
});
41

42
/**
43
 * Common verify callback for all login strategies.
44
 * It tries first authenticating user with profile.id agains fieldName in DB.
45
 * If not applicable, search for existing users with their email.
46
 * If still not applicable, create a user with currently given profile.
47
 *
48
 * @param {object} profile - passport profile object
49
 * @param {'facebookId'|'githubId'|'twitterId'|'googleId'|'instagramId'} fieldName - The elasticsearch ID field name in user document
50
 */
51
export async function verifyProfile(profile, fieldName) {
52
  // Find user with such user id
53
  //
54
  const users = await client.search({
3✔
55
    index: 'users',
56
    q: `${fieldName}:${profile.id}`,
57
  });
58

59
  if (getTotalCount(users.hits.total)) {
3✔
60
    return processMeta(users.hits.hits[0]);
1✔
61
  }
62

63
  const now = new Date().toISOString();
2✔
64
  const email = profile.emails && profile.emails[0].value;
2✔
65
  const avatar = profile.photos && profile.photos[0].value;
2✔
66
  const username = profile.displayName ? profile.displayName : profile.username;
2✔
67

68
  // Find user with such email
69
  //
70
  if (email) {
2!
71
    const usersWithEmail = await client.search({
2✔
72
      index: 'users',
73
      q: `email:${email}`,
74
    });
75

76
    if (getTotalCount(usersWithEmail.hits.total)) {
2✔
77
      const id = usersWithEmail.hits.hits[0]._id;
1✔
78
      // Fill in fieldName with profile.id so that it does not matter if user's
79
      // email gets changed in the future.
80
      //
81
      const updateUserResult = await client.update({
1✔
82
        index: 'users',
83
        id,
84
        doc: {
85
          [fieldName]: profile.id,
86
          updatedAt: now,
87
        },
88
        _source: true,
89
      });
90

91
      if (updateUserResult.result === 'updated') {
1!
92
        return processMeta({ ...updateUserResult.get, _id: id });
1✔
93
      }
94

UNCOV
95
      throw new Error(updateUserResult.result);
×
96
    }
97
  }
98

99
  // No user in DB, create one
100
  //
101
  const createUserResult = await client.index({
1✔
102
    index: 'users',
103
    document: {
104
      email,
105
      name: username,
106
      avatarUrl: avatar,
107
      [fieldName]: profile.id,
108
      createdAt: now,
109
      updatedAt: now,
110
    },
111
  });
112
  if (createUserResult.result === 'created') {
1!
113
    return processMeta(
1✔
114
      await client.get({
115
        index: 'users',
116
        id: createUserResult._id,
117
      })
118
    );
119
  }
120

UNCOV
121
  throw new Error(createUserResult.result);
×
122
}
123

124
if (process.env.FACEBOOK_APP_ID) {
3!
UNCOV
125
  passport.use(
×
126
    new FacebookStrategy(
127
      {
128
        clientID: process.env.FACEBOOK_APP_ID,
129
        clientSecret: process.env.FACEBOOK_SECRET,
130
        callbackURL: process.env.FACEBOOK_CALLBACK_URL,
131
        profileFields: ['id', 'displayName', 'photos', 'email'],
132
        graphAPIVersion: 'v19.0',
133
      },
134
      (token, tokenSecret, profile, done) =>
UNCOV
135
        verifyProfile(profile, 'facebookId')
×
UNCOV
136
          .then((user) => done(null, user))
×
137
          .catch(done)
138
    )
139
  );
140
}
141

142
if (process.env.TWITTER_CONSUMER_KEY) {
3!
143
  passport.use(
×
144
    new TwitterStrategy(
145
      {
146
        consumerKey: process.env.TWITTER_CONSUMER_KEY,
147
        consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
148
        callbackURL: process.env.TWITTER_CALLBACK_URL,
149

150
        // https://github.com/jaredhanson/passport-twitter/issues/67#issuecomment-275288663
151
        userProfileURL:
152
          'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true',
153
      },
154
      (token, tokenSecret, profile, done) =>
UNCOV
155
        verifyProfile(profile, 'twitterId')
×
UNCOV
156
          .then((user) => done(null, user))
×
157
          .catch(done)
158
    )
159
  );
160
}
161

162
if (process.env.GITHUB_CLIENT_ID) {
3!
UNCOV
163
  passport.use(
×
164
    new GithubStrategy(
165
      {
166
        clientID: process.env.GITHUB_CLIENT_ID,
167
        clientSecret: process.env.GITHUB_SECRET,
168
        callbackURL: process.env.GITHUB_CALLBACK_URL,
169
      },
170
      (token, tokenSecret, profile, done) =>
UNCOV
171
        verifyProfile(profile, 'githubId')
×
UNCOV
172
          .then((user) => done(null, user))
×
173
          .catch(done)
174
    )
175
  );
176
}
177

178
if (process.env.GOOGLE_CLIENT_ID) {
3!
UNCOV
179
  passport.use(
×
180
    new GoogleStrategy(
181
      {
182
        clientID: process.env.GOOGLE_CLIENT_ID,
183
        clientSecret: process.env.GOOGLE_SECRET,
184
        callbackURL: process.env.GOOGLE_CALLBACK_URL,
185
      },
186
      (token, tokenSecret, profile, done) =>
UNCOV
187
        verifyProfile(profile, 'googleId')
×
UNCOV
188
          .then((user) => done(null, user))
×
189
          .catch(done)
190
    )
191
  );
192
}
193

194
// Note: Instagram Basic Display API doesn't provide email
195
// and it needs the `user_media` scope to get profile photo.
196
if (process.env.INSTAGRAM_CLIENT_ID) {
3!
UNCOV
197
  passport.use(
×
198
    new InstagramStrategy(
199
      {
200
        clientID: process.env.INSTAGRAM_CLIENT_ID,
201
        clientSecret: process.env.INSTAGRAM_SECRET,
202
        callbackURL: process.env.INSTAGRAM_CALLBACK_URL,
203
      },
204
      (token, tokenSecret, profile, done) =>
UNCOV
205
        verifyProfile(profile, 'instagramId')
×
UNCOV
206
          .then((user) => done(null, user))
×
207
          .catch(done)
208
    )
209
  );
210
}
211

212
// Exports route handlers
213
//
214
export const loginRouter = Router()
3✔
215
  .use((ctx, next) => {
216
    // Memorize redirect in session
217
    //
218
    if (ctx.query.redirect_to) {
7✔
219
      if (!isAllowedCallbackUrl(ctx.query.redirect_to)) {
5✔
220
        const err = new Error(
2✔
221
          '`redirect_to` is not in the allowed callback URLs'
222
        );
223
        err.status = 400;
2✔
224
        err.expose = true;
2✔
225
        throw err;
2✔
226
      }
227
      ctx.session.redirectTo = ctx.query.redirect_to;
3✔
228
      ctx.session.state = ctx.query.state;
3✔
229
      ctx.session.appId = ctx.query.appId || 'RUMORS_SITE';
3✔
230
      ctx.session.redirect = undefined;
3✔
231
      ctx.session.origin = undefined;
3✔
232
    } else if (ctx.query.redirect) {
2✔
233
      if (!ctx.query.redirect.startsWith('/')) {
1!
NEW
UNCOV
234
        const err = new Error(
×
235
          '`redirect` must present in query string and start with `/`'
236
        );
NEW
UNCOV
237
        err.status = 400;
×
NEW
UNCOV
238
        err.expose = true;
×
NEW
UNCOV
239
        throw err;
×
240
      }
241
      ctx.session.appId = ctx.query.appId || 'RUMORS_SITE';
1!
242
      ctx.session.redirect = ctx.query.redirect;
1✔
243
      ctx.session.redirectTo = undefined;
1✔
244
      ctx.session.state = undefined;
1✔
245
      const referer = ctx.get('Referer');
1✔
246
      if (referer) {
1!
247
        try {
1✔
248
          ctx.session.origin = new URL(referer).origin;
1✔
249
        } catch (e) {
250
          // Ignore invalid Referer
251
        }
252
      }
253
    } else {
254
      const err = new Error(
1✔
255
        'Either `redirect_to` (BFF flow) or `redirect` (legacy, must start with `/`) is required'
256
      );
257
      err.status = 400;
1✔
258
      err.expose = true;
1✔
259
      throw err;
1✔
260
    }
261
    return next();
4✔
262
  })
263
  .get('/facebook', passport.authenticate('facebook', { scope: ['email'] }))
264
  .get('/twitter', passport.authenticate('twitter'))
265
  .get('/github', passport.authenticate('github', { scope: ['user:email'] }))
266
  .get('/google', passport.authenticate('google', { scope: ['profile email'] }))
267
  .get(
268
    '/instagram',
269
    passport.authenticate('instagram', { scope: ['user_profile'] })
270
  );
271

272
const handlePassportCallback = (strategy) => (ctx, next) =>
15✔
273
  passport.authenticate(strategy, (err, user) => {
×
274
    if (!err && !user) err = new Error('No such user');
×
275

276
    if (err) {
×
277
      err.status = 401;
×
278
      throw err;
×
279
    }
280

281
    ctx.login(user);
×
282
  })(ctx, next);
283

284
export const authRouter = Router()
3✔
285
  .use(async (ctx, next) => {
286
    // Perform redirect after login
287
    //
288
    if (
5✔
289
      (!ctx.session.redirect || !ctx.session.appId) &&
10!
290
      !ctx.session.redirectTo
291
    ) {
292
      const err = new Error(
1✔
293
        '`appId` and `redirect` must be set before. Did you forget to go to /login/*?'
294
      );
295
      err.status = 400;
1✔
296
      err.expose = true;
1✔
297
      throw err;
1✔
298
    }
299

300
    await next();
4✔
301

302
    if (ctx.session.redirectTo) {
4!
303
      const userId = ctx.state.user?.userId;
4✔
304
      if (!userId) {
4✔
305
        const err = new Error(
1✔
306
          'Authenticated user has no id; cannot mint authorization code'
307
        );
308
        err.status = 401;
1✔
309
        err.expose = true;
1✔
310
        throw err;
1✔
311
      }
312
      const code = await signShortLivedJWT(userId);
3✔
313
      const redirectUrl = new URL(ctx.session.redirectTo);
3✔
314
      redirectUrl.searchParams.set('code', code);
3✔
315
      if (ctx.session.state) {
3!
316
        redirectUrl.searchParams.set('state', ctx.session.state);
3✔
317
      }
318
      ctx.redirect(redirectUrl.href);
3✔
319
      // eslint-disable-next-line require-atomic-updates
320
      ctx.session.redirectTo = undefined;
3✔
321
      // eslint-disable-next-line require-atomic-updates
322
      ctx.session.state = undefined;
3✔
323
      // eslint-disable-next-line require-atomic-updates
324
      ctx.session.appId = undefined;
3✔
325
    } else {
NEW
326
      let basePath = '';
×
NEW
327
      if (
×
328
        ctx.session.appId === 'RUMORS_SITE' ||
×
329
        ctx.session.appId === 'DEVELOPMENT_FRONTEND'
330
      ) {
NEW
331
        const validOrigins = (
×
332
          process.env.RUMORS_SITE_REDIRECT_ORIGIN || ''
×
333
        ).split(',');
334

NEW
335
        basePath =
×
NEW
336
          validOrigins.find((o) => o === ctx.session.origin) || validOrigins[0];
×
337
      }
338

339
      // TODO: Get basePath from DB for other client apps
NEW
340
      try {
×
NEW
341
        ctx.redirect(new URL(ctx.session.redirect, basePath).href);
×
342
      } catch (err) {
NEW
343
        err.status = 400;
×
NEW
344
        err.expose = true;
×
NEW
345
        throw err;
×
346
      }
347

348
      // eslint-disable-next-line require-atomic-updates
NEW
349
      ctx.session.appId = undefined;
×
350
      // eslint-disable-next-line require-atomic-updates
NEW
UNCOV
351
      ctx.session.redirect = undefined;
×
352
    }
353
  })
354
  .get('/facebook', handlePassportCallback('facebook'))
355
  .get('/twitter', handlePassportCallback('twitter'))
356
  .get('/github', handlePassportCallback('github'))
357
  .get('/google', handlePassportCallback('google'))
358
  .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