Search code examples
node.jsgoogle-oauthpassport.js

Passportjs Google Oauth2 Unexpected behaviours


I'm trying to implement Login with Google (OAuth2) using Nest.js - Passport.js and passport-google-oauth20 but facing unexpected behaviors. When it hits the login route - instead of being redirected to Google Consent - the request is completed immediately by Google and sent to the success callback - (that's obviously due to consent done early) but success callback isn't seemingly doing anything, rather my original request 'Login with Google' is errored by XHR.

So flow look like:

Login -> Auto Consent -> Success Callback -> [black-hole]

Front-end App Error <- original request

I don't know what is going wrong with it. Could you please figure out?

If I copy the URL the Nest.js redirects user to (like accounts.google.com/...)
and open that directly in a new tab, that prompts for the Consent and
everything works perfect as desired.

Strategy

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
  private logger = new Logger(GoogleStrategy.name);

  constructor(
    private authService: AuthService,
    private config: ConfigService,
  ) {
    super({
      clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
      clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
      callbackURL: config.getOrThrow('GOOGLE_CLIENT_CALLBACK'),
      scope: ['email', 'profile'],
    });
  }

  authorizationParams(): { [key: string]: string } {
    return {
      access_type: 'offline',
      prompt: 'consent',
    };
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    cb: (err: any, data?: any) => any,
  ): Promise<any> {
    try {
      const { name, id, emails, photos } = profile;
      const user = await this.authService.oauthFindOrCreate(
        'google',
        id,
        {
          firstName: name.givenName,
          lastName: name.familyName,
          email: emails[0].value,
          profilePicture: photos[0].value,
        },
        accessToken,
        refreshToken,
      );

      return cb(null, user);
    } catch (err) {
      this.logger.error(err);
      return cb(err);
    }
  }

  async serializeUser(user: Document, done: (err: any, id?: any) => any) {
    done(null, user._id);
  }

  async deserializeUser(id: any, done: (err: any, user?: any) => any) {
    const user = await this.authService.findById(id);
    done(null, user);
  }
}```

Controller

@Controller('auth')
export class AuthController {
  constructor(
    private authService: AuthService,
    private config: ConfigService,
  ) {}

  @Post('login/google')
  @UseGuards(AuthGuard('google'))
  oauthGoogle() {}

  @Get('login/google/success')
  @UseGuards(AuthGuard('google'))
  async oauthGoogleSuccess(
    @Auth() user: UserDocument,
    @Res({ passthrough: true }) res: Response,
  ): Promise<void> {
    const data = await this.authService.generateLogin(user);
    res.cookie('token', data.token);
    res.redirect(this.config.getOrThrow('APP_HOME'));
  }
}

Request function (called by another from login page)

# Example call
req('POST', '/auth/login/google', {}, undefined, 'no-cors')
  .then(() => {
    // would not be here
  }).catch((err) => {
    // WOULD BE HERE ONCE Google redirects to /api/v1/auth/login/google/success
  });
export async function req<
  RequestData = EmptyObject | FormData,
  ResponseData = EmptyObject,
>(
  method: "POST" | "PATCH" | "PUT",
  path: string,
  data: RequestData,
  token?: string,
  mode: HttpCors = "cors",
): Promise<HttpResponse<ResponseData>> {
  return fetch(`${appConfig.apiBasePath}${path}`, {
    method,
    headers: {
      ...(data instanceof FormData
        ? {}
        : { "Content-Type": "application/json" }),

      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: data instanceof FormData ? data : JSON.stringify(data),
    credentials: "include",
    mode,
  })
    .then(async (res) => {
      if (!res.ok) {
        throw (await res.json()) as ResponseData;
      }

      return res.json() as Promise<HttpResponse<ResponseData>>;
    })
    .then(
      (res: HttpResponse<ResponseData>) => res as HttpResponse<ResponseData>,
    )
    .catch((err) => {
      if (err?.statusCode && err?.message) {
        throw err;
      }

      throw { statusCode: 500, message: "Something went wrong!", error: true };
    });
}

Solution

  • Actually I was making an XHR request with POST method to Login Route - while it should have been a Direct GET request to the login route from where the request would have been initiated to OAuth Server and respectively backed to the server and the API server would indeed be responsible to direct the user to proper after login page.

    I'm able to uncover the issue - it is too stupid but subtle enough that it took my hours. I don't know the deeper details, but still posting answer so if someone come across the situation.

    If someone else can explain the actual behavior - that would be really helpful - so would still be looking for a better answer.