jaredhanson / passport-google-oauth2

Google authentication strategy for Passport and Node.js.
https://www.passportjs.org/packages/passport-google-oauth20/?utm_source=github&utm_medium=referral&utm_campaign=passport-google-oauth20&utm_content=about
MIT License
820 stars 153 forks source link

Can't persist data to session store inside of GoogleStrategy callback #92

Closed jmcilhargey closed 1 year ago

jmcilhargey commented 1 year ago

What

Can't persist request.session data inside of the GoogleStrategy callback using the Express Request object argument..

Expected

request.session.oAuth.google contains the persisted session data for subsequent requests

Actual

request.session.oAuth.google is undefined. The request.session.save() does not save the updated session to the store..

Logging the request.session before and after calling save() indicates the property is attached to the request object during that request context.. but is not persisted.

I'm able to persist changes to session store for other OAuth strategies I'm using outside of Passport calling request.session.save() on this same server..

Am I missing something?

Steps to reproduce

Server:

  app.use(bodyParser.json())
  app.use(bodyParser.urlencoded({ extended: true }))
  app.use(cookieParser())

  await sessionMiddleware(app)
  passportMiddleware(app)

Session Middleware:

export async function sessionMiddleware(app: Application) {
  const Redis = RedisStore(session)
  const client = createClient({
    url: getRedisUrl(),
    legacyMode: true,
  })

  await client.connect()

  app.use(
    session({
      store: new Redis({ client, ttl: SESSION_EXPIRY / 1000 }),
      secret: getSessionSecret(),
      resave: true,
      saveUninitialized: false,
      cookie: { secure: true, httpOnly: true, maxAge: SESSION_EXPIRY },
    })
  )
}

Passport Middleware:

export async function passportMiddleware(app: Application) {
  app.use(passport.initialize())
  app.use(passport.session())

  passport.serializeUser<UserSessionData>((user, done) => {
    process.nextTick(() => {
      const { id, email, firstName, lastName, roles, verified } = user
      done(null, { id, email, firstName, lastName, roles, verified })
    })
  })

  passport.deserializeUser(async (user: UserSessionData, done) => {
    process.nextTick(() => {
      console.log('deserializeUser', user)
      return done(null, user)
    })
  })

  passport.use(localStrategy)

  passport.use(googleStrategy)
}

export const localStrategy = new Local.Strategy(
  STRATEGY_OPTIONS,
  async function (
    email: string,
    password: string,
    done: (error: Error | null, user?: User) => void
  ): Promise<void> {
    try {
      const user = await getUserByEmail(email)

      if (!user) {
        done(new Error('No user with that email'))
      } else {
        validateUserPassword(user, password)
          ? done(null, user)
          : done(new Error('Invalid email and password combination'))
      }
    } catch (error) {
      done(new Error('Something went wrong'))
    }
  }
)

export const googleStrategy = new GoogleStrategy(
  {
    clientID: getGoogleClientId(),
    clientSecret: getGoogleClientSecret(),
    callbackURL: `https://${getDomainName()}/api/google/callback`,
    passReqToCallback: true,
    scope: GOOGLE_LOGIN_SCOPES,
  },
  async (
    request: Request,
    accessToken: string,
    refreshToken: string,
    params: GoogleCallbackParameters,
    profile: Profile,
    done: (error: Error | null, user?: User) => void
  ) => {
    try {
      const { emails, name } = profile
      const email = emails[0].value
      const { expires_in } = params
      let user = await getUserByEmail(email)

      if (!user) {
        user = await insertUser(email, name.givenName, name.familyName)
      }

      await setGoogleSession(
        request,
        accessToken,
        refreshToken,
        getExpirationTimeStamp(expires_in)
      )

      await upsertAuthCredentials(
        user.id,
        AuthProviderName.Google,
        accessToken,
        refreshToken
      )

      done(null, user)
    } catch (error) {
      done(error, null)
    }
  }
)

Google Session Setter:

export async function setGoogleSession(
  request: Request,
  accessToken: string,
  refreshToken: string,
  expiresAt: number
): Promise<void> {
  request.session.oAuth = request.session.oAuth ?? {}
  request.session.oAuth.google = {
    accessToken,
    refreshToken,
    expiresAt,
  }

  await saveSessionAsync(request)
}

Environment

"engines": { "node": ">=16.0.0", "npm": ">=7.0.0" }, "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", "passport": "^0.6.0",

jmcilhargey commented 1 year ago

Looks like passport.serialize just wipes out everything else on the session including other keys that may be part of the session (I was trying to set request.session.oAuth to store additional credentials..)

A work around could be to set additional optional properties inside of the serialize function.. But I feel like passport should allow other things to be set on the session outside of it's own property..

Here's the workaround for example with Google and local auth - But if you are using something outside of Passport it looks like it's not possible to use the express-session with passport bc it gets wiped out?

passport.serializeUser<UserSessionData>((user, done) => {
  process.nextTick(() => {
    const { id, email, firstName, lastName, roles, verified, google } = user; // Serialize google data too if using Google Oauth2
    done(null, { id, email, firstName, lastName, roles, verified, google });
  });
});

interface UserSessionData {
  id: number;
  email: string;
  firstName: string;
  lastName: string;
  roles: string[];
  verified: boolean;
  google?: {
    accessToken: string;
    refreshToken: string;
    expiresAt: number;
  };
}
jmcilhargey commented 1 year ago

Okay.. After inspecting the source code I see the merge function and I found the answer to my question - Make sure that keepSessionInfo: true is set for all Passport strategies!

export function googleRoute(router: IRouter) {
  router.get(
    GoogleRoutes.Auth,
    passport.authenticate('google', {
      scope: GOOGLE_LOGIN_SCOPES,
      accessType: 'offline',
      prompt: 'consent',
      keepSessionInfo: true,
    })
  )

  router.get(
    GoogleRoutes.Callback,
    passport.authenticate('google', {
      failureRedirect: AppRoutes.Login,
      keepSessionInfo: true,
    }),
    function (request, response) {
      response.redirect(AppRoutes.Home)
    }
  )
}