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

cofacts / rumors-api / 25202998523

01 May 2026 05:00AM UTC coverage: 82.229% (+1.2%) from 80.99%
25202998523

Pull #386

github

web-flow
Merge 19fd523c3 into 985425be6
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 %.

111 of 129 new or added lines in 7 files covered. (86.05%)

20 existing lines in 1 file now uncovered.

1721 of 2015 relevant lines covered (85.41%)

17.28 hits per line

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

62.69
/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
    } else if (ctx.query.redirect) {
2✔
231
      if (!ctx.query.redirect.startsWith('/')) {
1!
NEW
UNCOV
232
        const err = new Error(
×
233
          '`redirect` must present in query string and start with `/`'
234
        );
NEW
235
        err.status = 400;
×
NEW
236
        err.expose = true;
×
NEW
UNCOV
237
        throw err;
×
238
      }
239
      ctx.session.appId = ctx.query.appId || 'RUMORS_SITE';
1!
240
      ctx.session.redirect = ctx.query.redirect;
1✔
241
      const referer = ctx.get('Referer');
1✔
242
      if (referer) {
1!
243
        try {
1✔
244
          ctx.session.origin = new URL(referer).origin;
1✔
245
        } catch (e) {
246
          // Ignore invalid Referer
247
        }
248
      }
249
    } else {
250
      const err = new Error(
1✔
251
        'Either `redirect_to` (BFF flow) or `redirect` (legacy, must start with `/`) is required'
252
      );
253
      err.status = 400;
1✔
254
      err.expose = true;
1✔
255
      throw err;
1✔
256
    }
257
    return next();
4✔
258
  })
259
  .get('/facebook', passport.authenticate('facebook', { scope: ['email'] }))
260
  .get('/twitter', passport.authenticate('twitter'))
261
  .get('/github', passport.authenticate('github', { scope: ['user:email'] }))
262
  .get('/google', passport.authenticate('google', { scope: ['profile email'] }))
263
  .get(
264
    '/instagram',
265
    passport.authenticate('instagram', { scope: ['user_profile'] })
266
  );
267

268
const handlePassportCallback = (strategy) => (ctx, next) =>
15✔
269
  passport.authenticate(strategy, (err, user) => {
×
270
    if (!err && !user) err = new Error('No such user');
×
271

272
    if (err) {
×
273
      err.status = 401;
×
274
      throw err;
×
275
    }
276

277
    ctx.login(user);
×
278
  })(ctx, next);
279

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

296
    await next();
4✔
297

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

NEW
331
        basePath =
×
NEW
332
          validOrigins.find((o) => o === ctx.session.origin) || validOrigins[0];
×
333
      }
334

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

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