Search code examples
angularnestjspassport.jspassport-google-oauth

How to redirect to a specific path after authentication in Nest using Passport


I have an API backend implemented using Nest, and the app frontend is implemented using Angular. It implements authentication using Passport.

The Angular app runs at http://localhost:2080, whereas the Nestjs app run at http://localhost:2180 and is mounted at http://localhost:2080/api using HTTP proxy middleware.

I have implemented Local, Magic Login, Google and Facebook strategies for authentication.

When a user authenticates using Local, Magic Login, Google, or Facebook strategies, on the frontend, they are redirected to the / path.

There is a /app/manage-profile path, which allows a logged in user to connect or disconnect their Google or Facebook account to their local account.

I have the following methods in my auth controller.

// this corresponds to /api/auth/login/google
@UseGuards(AuthGoogleAuthGuard)
@Get('google')
async loginWithGoogle(): Promise<void> {
  // NOTE : do nothing
}

// this corresponds to /api/auth/connect/google
@UseGuards(AuthGoogleAuthGuard)
@Get('google')
async connectGoogle(): Promise<void> {
  // NOTE : do nothing
}

// this corresponds to /api/auth/accept/google
@UseGuards(AuthGoogleAuthGuard)
@Get('google')
async acceptGoogle(@Req() req: Request, @Res() res: Response): Promise<void> {
  await this.tokenService.invalidateToken(extractSession(req));

  const result: Result<Token> = await this.tokenService.generateToken(req.user as User);

  if (result.success) {
    const redirectURL: URL = new URL('/app/accept/google', AUTH_CONSTANTS.Strategies.Google.redirectURL);

    redirectURL.searchParams.set('token', result.data.token);

    res.redirect(redirectURL.toString());
  } else {
    const redirectURL: URL = new URL('/app/login', AUTH_CONSTANTS.Strategies.Google.redirectURL);

    res.redirect(redirectURL.toString());
  }
}

// this corresponds to /api/auth/disconnect/google
@UseGuards(AuthJwtAuthGuard)
@Get('google')
async disconnectGoogle(@Req() req: Request): Promise<Result<Token>> {
  const user: User = await this.userService.ensureUserNotWithProvider(req.user as User, ProviderType.GOOGLE);

  await this.tokenService.invalidateToken(extractSession(req));

  return await this.tokenService.generateToken(user);
}

I am struggling to redirect the user to / when they are logging in, but to /app/manage-profile when they are connecting their account. This is because when the user is redirected back to my app from Google after authentication, they are redirected back to /api/accept/google. In there, I can't differentiate whether they initiated via Login or Connect.

Can someone point me in the right direction?


Solution

  • Passport allows a state to be sent to the OAuth provider, which is then echoed back to the API backend sending the uset to the OAuth provider for authentication.

    To return the user back to a particular route in the app frontend, an item of state should be set to identify the location.

    Then in the callback, that state should be interpreted and the location in the app frontend set to the desired on.

    Suppose we are using Google as the OAuth provider.

    Step 1: user initiates "login" or "connect" on the app frontend

    The frontend will redirect the user to /api/login/google?path=/ or /api/connect/google?path=/app/manage-profile, as the case may be.

    Step 2: user is redirected by the API backend to Google

    The backend will extract the value of path query parameter from the request, and set the value of state to the base64-encoded value of the path query parameter.

    NB: some other memoization method can be used here too, but for my purposes this was enough

    Step 3: user is received back by the API backend from Google

    The /api/accept/google endpoint will be called by Google after the user successfully authenticates on Google. The request will include the state we set in Step 2.

    The state will be "interpreted" -- in our case, just base-64 decoded -- the value of path extracted and the user will be redirected to the app frontend using the value of path.

    Important:

    The way to pass state in Nest during OAuth authorization using Passport is to implement the getAuthenticationOptions in the AuthGuard implementation for the Passport strategy.

    For example,

    import { ExecutionContext, Injectable } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { AuthGuard } from '@nestjs/passport';
    
    @Injectable()
    export class AuthGoogleAuthGuard extends AuthGuard('google') {
      constructor(
        private configService: ConfigService
      ) {
        super({
          accessType: 'offline'
        });
      }
    
      getAuthenticateOptions(context: ExecutionContext) {
        // get the path out of the query parameters
        const { path } = context.switchToHttp().getRequest().query;
    
        // create a JSON object with all of the state that needs to be persisted during the OAuth flow
        const json: string = JSON.stringify({ path });
    
        // stringify the state, and base64-encode it
        // alternatively, this state object can be persisted to a cache, and the cache key sent as the state
        const state: string = Buffer
          .from(json, 'utf-8')
          .toString('base64');
    
        // set the state; this will be returned back in the callback from the OAuth provider
        return {
          state
        };
      }
    }
    

    Then in the accept callback...

    @UseGuards(AuthGoogleAuthGuard)
    @Get('accept/google')
    async acceptGoogle(@Req() req: Request, @Res() res: Response, @Query('state') state: string): Promise<void> {
      // decode the state query parameter
      const json: string = Buffer
        .from(state, 'base64')
        .toString('utf-8');
    
      // if we had used a cache to store the state, here we can pull the state out from the cache using the key received in the state
    
      // parse it back into a JSON object
      const { path }: { path: string; } = JSON.parse(json) as { path: string; };
    
      await this.tokenService.invalidateToken(extractSession(req));
    
      const result: Result<Token> = await this.tokenService.generateToken(req.user as User);
    
      if (result.success) {
        const redirectURL: URL = new URL('/app/accept/google', AUTH_CONSTANTS.Strategies.Google.redirectURL);
    
        redirectURL.searchParams.set('token', result.data.token);
        // include the path received in the state in the URL redirecting user to the app frontend
        redirectURL.searchParams.set('path', path);
    
        res.redirect(redirectURL.toString());
      } else {
        const redirectURL: URL = new URL('/app/login', AUTH_CONSTANTS.Strategies.Google.redirectURL);
    
        res.redirect(redirectURL.toString());
      }
    }