rkusa/koa-passport

404 sent to client (browser) during google callback, but the user data is retrieved.

Panoplos opened this issue · 7 comments

When calling passport.authenticate(...) in the OAuth2 callback stage of the authentication sequence for Google OAuth2 as follows:

const GoogleStrategy = require('passport-google-oauth2').Strategy
passport.use(new GoogleStrategy({
    scope: ['email', 'profile'],
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: 'http://localhost:' + 7000 + '/auth/google/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    console.log(profile)
    fetchUser().then(user => done(null, user))
  }
))


app.use(route.get('/auth/google', async (ctx, next) => {
  console.log(`/api/google/singin called with\n ctx -> ${JSON.stringify(ctx)} && next -> ${next}`)
    console.log(`ctx.req.headers -> ${JSON.stringify(ctx.req.headers)}`)
    console.log(`ctx.req.rawHeaders -> ${ctx.req.rawHeaders}`)
    passport.authenticate('google')(ctx,next)
  }
))
  
app.use(route.get('/auth/google/callback', async (ctx, next) => {
  passport.authenticate('google', async (err, user, info) => {
    console.log(`/auth/google/signin/callback -> passport.authenticate('google') callback called with\nerr -> ${JSON.stringify(err)}\nuser -> ${JSON.stringify(user)}\ninfo -> ${JSON.stringify(info)}`)
    if (user === false) {
      ctx.redirect('/')
      await next()
    } else {
      ctx.login(user)
      ctx.redirect('/app')
      await next()
    }
  })(ctx, next)
}))

Koa is sending 404 headers to the client (browser),

screen shot 2017-04-17 at 6 17 08 pm

but the anonymous function callback is called with the actual user data.

The debug output shows the sequence of calls (removed personal info):

  <-- GET /favicon.ico
  --> GET /favicon.ico 302 6ms 33b
  <-- GET /
  --> GET / 200 5ms 719b
  <-- GET /auth/google
/api/google/singin called with
 ctx -> {"request":{"method":"GET","url":"/auth/google","header":{"host":"localhost:7000","connection":"keep-alive","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8","dnt":"1","referer":"http://localhost:7000/","accept-encoding":"gzip, deflate, sdch, br","accept-language":"en-US,en;q=0.8,ja;q=0.6","cookie":"koa.sid.sig=O9oTTBivYar3gy7JuZtdpDgvTO0"}},"response":{"status":404,"message":"Not Found","header":{}},"app":{"subdomainOffset":2,"proxy":true,"env":"development"},"originalUrl":"/auth/google","req":"<original node req>","res":"<original node res>","socket":"<original node socket>"} && next -> function next() {
          return dispatch(i + 1)
        }
ctx.req.headers -> {"host":"localhost:7000","connection":"keep-alive","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8","dnt":"1","referer":"http://localhost:7000/","accept-encoding":"gzip, deflate, sdch, br","accept-language":"en-US,en;q=0.8,ja;q=0.6","cookie":"koa.sid.sig=O9oTTBivYar3gy7JuZtdpDgvTO0"}
ctx.req.rawHeaders -> Host,localhost:7000,Connection,keep-alive,Upgrade-Insecure-Requests,1,User-Agent,Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36,Accept,text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8,DNT,1,Referer,http://localhost:7000/,Accept-Encoding,gzip, deflate, sdch, br,Accept-Language,en-US,en;q=0.8,ja;q=0.6,Cookie,koa.sid.sig=O9oTTBivYar3gy7JuZtdpDgvTO0
  --> GET /auth/google 302 4ms 0b
  <-- GET /auth/google/callback?code=4/vE4A-Yl3nZS07-x61cgapHEf2UhX3b-ptn4K0wdSLv0
  --> GET /auth/google/callback?code=4/vE4A-Yl3nZS07-x61cgapHEf2UhX3b-ptn4K0wdSLv0 404 9ms -
  <-- GET /favicon.ico
  --> GET /favicon.ico 302 2ms 33b
  <-- GET /
  --> GET / 200 22ms 719b
{ provider: 'google',
  id: 'XXXXXXXXXXXXXXX',
  displayName: XXXXXXXXXXXXXXX',
  name: { familyName: 'XXXXXXXXXXXXXXX', givenName: 'XXXXXXXXXXXXXXX' },
  isPerson: true,
  isPlusUser: true,
  language: 'en',
  emails: [ { value: 'XXXXXXXXXXXXXXX', type: 'account' } ],
  email: 'XXXXXXXXXXXXXXX',
  gender: 'XXXXXXXXXXXXXXX',
  photos: [ { value: 'https://lh5.googleusercontent.com/XXXXXXXXXXX/photo.jpg?sz=50' } ],
  _raw: '{\n "kind": "plus#person",\n "etag": "\\"XXXXXXXXXXXXXX\\"",\n "gender": "XXXXXXXXX",\n "emails": [\n  {\n   "value": "XXXXXXXXXXXXXXX",\n   "type": "account"\n  }\n ],\n "objectType": "person",\n "id": "XXXXXXXXXXXXXXX",\n "displayName": "XXXXXXXXXXXXXXX",\n "name": {\n  "familyName": "XXXXXXXXXXXXXXX",\n  "givenName": "XXXXXXXXXXXXXXX"\n },\n "url": "https://plus.google.com/XXXXXXXXXXXXXXX",\n "image": {\n  "url": "https://lh5.googleusercontent.com/XXXXXXXXXXXXXXX/photo.jpg?sz=50",\n  "isDefault": false\n },\n "isPlusUser": true,\n "language": "en",\n "circledByCount": 0,\n "verified": false\n}\n',
  _json: 
   { kind: 'plus#person',
     etag: '"XXXXXXXXXXXXXXX"',
     gender: 'XXXXXXXXXXXXXXX',
     emails: [ [Object] ],
     objectType: 'person',
     id: 'XXXXXXXXXXXXXXX',
     displayName: 'XXXXXXXXXXXXXXX',
     name: { familyName: 'XXXXXXXXXXXXXXX', givenName: 'XXXXXXXXXXXXXXX' },
     url: 'https://plus.google.com/XXXXXXXXXXXXXXX',
     image: 
      { url: 'https://lh5.googleusercontent.com/XXXXXXXXXXXXXXX/photo.jpg?sz=50',
        isDefault: false },
     isPlusUser: true,
     language: 'en',
     circledByCount: 0,
     verified: false } }
/auth/google/signin/callback -> passport.authenticate('google') callback called with
err -> null
user -> {"id":1,"username":"test","password":"test"}
info -> {}
(node:54360) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 4): Error: Can't set headers after they are sent.
rkusa commented

According to your logs, you get the following error: UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 4): Error: Can't set headers after they are sent.

This could be because you do a redirect ctx.redirect('/app') but still continue in the middleware chain afterwards with await next().

Furthermore, it I think should be await ctx.login(user), but this is something I forgot to mention in the README (will be updated).

Sorry to say that this is not the problem, I still get the 404 and "can't set headers after they are sent" message, even with the following code:

app.use(route.get('/auth/google/callback',

  /*passport.authenticate('google', { failureRedirect: '/', successRedirect: '/app' })))*/

 async (ctx, next) => {
  console.log(`/auth/google/singin called with\n ctx -> ${JSON.stringify(ctx)} && next -> ${next}`)
  console.log(`ctx.req.headers -> ${JSON.stringify(ctx.req.headers)}`)
  console.log(`ctx.req.rawHeaders -> ${ctx.req.rawHeaders}`)
  passport.authenticate('google', async (err, user, info) => {
    console.log(`/auth/google/signin/callback -> passport.authenticate('google') callback called with\nerr -> ${JSON.stringify(err)}\nuser -> ${JSON.stringify(user)}\ninfo -> ${JSON.stringify(info)}`)
    if (user === false) {
      ctx.redirect('/')
    } else {
      await ctx.login(user)
      ctx.redirect('/app')
    }
  })(ctx)
}))

I have been looking at the core passport stuff, and there seems to be a race condition where Application.handleResponse -> respond(ctx) is being called with a 404 (default) and empty body after processing the google callback but before the "verify" action occurs in the strategy. I have managed to get around this by setting ctx.respond = false before making the call to authenticate, but this is not a fix as now the session is not getting saved correctly after the user is serialised, and is getting reset on the next request (for "/app").

rkusa commented

I missed the following when first having a look, I think you have to call await passport.authenticate() when calling it manually

And we have liftoff!!

For documentation purposes, here is the code that works:

app.use(route.get('/auth/google/callback', async (ctx, next) => {
  await passport.authenticate('google', async (err, user, info) => {
    if (user === false) {
      ctx.redirect('/')
    } else {
      await ctx.login(user)
      ctx.redirect('/app')
    }
  })(ctx)
}))
rkusa commented

Awesome!

I think the code could be simplified into into:

app.use(route.get('/auth/google/callback', passport.authenticate('google', async (err, user, info) => {
    if (user === false) {
      ctx.redirect('/')
    } else {
      await ctx.login(user)
      ctx.redirect('/app')
    }
})))

@rkusa silly question: where does ctx come from in the simplified example?

It is sent to the function returned by passport.authenticate(...).