DEV Community

Cover image for NestJS Authentication with OAuth2.0: Adding Mobile Apps Support
Afonso Barracha
Afonso Barracha

Posted on • Updated on

NestJS Authentication with OAuth2.0: Adding Mobile Apps Support

Series Intro

This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:

And it is divided in 5 parts:

  • Configuration and operations;
  • Express Local OAuth REST API;
  • Fastify Local OAuth REST API;
  • Apollo Local OAuth GraphQL API;
  • Adding External OAuth Providers to our API;

Lets start an extra 6th part.

Tutorial Intro

Originally this series was made in a way that only works for Web Apps that use web browsers, however nowadays most people use smartphones to interact with the internet. For the most part, mobile apps do not support HTTP-only cookies; hence, we need to return information about the tokens, including the refresh token and the access token validity period, for better storage and management.

On this tutorial we will make the necessary changes to our API to make it mobile friendly.

  1. Returning tokenType, expiresIn and refreshToken with the authentication response.
  2. Add optional body with refreshToken parameter to the logout and refresh endpoints.
  3. Add a token endpoint for external providers to remove the need for HTTP only cookies.

Local Mobile friendly API

For local authentication we need to make two changes:

  1. Update the response to have tokenType, expiresIn and refreshToken.
  2. Allow the user to pass the refreshToken on the logout and refresh access endpoints.

Updated Response Mapper

Start by fixing the IAuthResponse interface and AuthResponseMapper class:

  • src/auth/interfaces/auth-response.interface.ts:

    // ...
    
    export interface IAuthResponse {
      // ...
      // New fields
      refreshToken: string;
      tokenType: string;
      expiresIn: number;
    }
    
  • src/auth/mappers/auth-response.mapper.ts:

    // ...
    
    export class AuthResponseMapper implements IAuthResponse {
      // ...
      // New fields
    
      @ApiProperty({
        description: 'Refresh token',
        example:
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
        type: String,
      })
      public readonly refreshToken: string;
    
      @ApiProperty({
        description: 'Token type',
        example: 'Bearer',
        type: String,
      })
      public readonly tokenType: string;
    
      @ApiProperty({
        description: 'Expiration period in seconds',
        example: 3600,
        type: Number,
      })
      public readonly expiresIn: number;
    
      constructor(values: IAuthResponse) {
        Object.assign(this, values);
      }
    
      public static map(result: IAuthResult): AuthResponseMapper {
        return new AuthResponseMapper({
          user: AuthResponseUserMapper.map(result.user),
          accessToken: result.accessToken,
          refreshToken: result.refreshToken,
          tokenType: 'Bearer',
          expiresIn: result.expiresIn,
        });
      }
    }
    

Services Updates

To get the access token validity period we need to create a getter for it on jwt.service.ts:

@Injectable()
export class JwtService {
  // ...

  public get accessTime(): number {
    return this.jwtConfig.access.time;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then on the auth.service.ts update all methods that return Promise<IAuthResult> with the new response format:

@Injectable()
export class AuthService {
  // ...

  public async confirmEmail(
    dto: ConfirmEmailDto,
    domain?: string,
  ): Promise<IAuthResult> {
    // ...
    return {
      user,
      accessToken,
      refreshToken,
      expiresIn: this.jwtService.accessTime,
    };
  }

  // ...

  public async signIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
    // ...

    return {
      user,
      accessToken,
      refreshToken,
      expiresIn: this.jwtService.accessTime,
    };
  }

  // ...

  public async refreshTokenAccess(
    refreshToken: string,
    domain?: string,
  ): Promise<IAuthResult> {
    // ...

    return {
      user,
      accessToken,
      refreshToken: newRefreshToken,
      expiresIn: this.jwtService.accessTime,
    };
  }

  // ...

  public async updatePassword(
    userId: number,
    dto: ChangePasswordDto,
    domain?: string,
  ): Promise<IAuthResult> {
    // ...
    return {
      user,
      accessToken,
      refreshToken,
      expiresIn: this.jwtService.accessTime,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Refresh token in the body

Now for /refresh-access and /logout we need to be able to pass the refreshToken both from the body and from the cookie.

Start by creating a new refresh-access.dto.ts:

import { ApiProperty } from '@nestjs/swagger';
import { IsJWT, IsOptional, IsString } from 'class-validator';

export abstract class RefreshAccessDto {
  @ApiProperty({
    description: 'The JWT token sent to the user email',
    example:
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
    type: String,
  })
  @IsOptional()
  @IsString()
  @IsJWT()
  public refreshToken?: string;
}
Enter fullscreen mode Exit fullscreen mode

And update the AuthController's refreshTokenFromReq method to take the dto:

// ...
import { RefreshAccessDto } from './dtos/refresh-access.dto';
// ...

@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
  // ...

  private refreshTokenFromReq(
    req: FastifyRequest,
    dto?: RefreshAccessDto,
  ): string {
    const token: string | undefined = req.cookies[this.cookieName];

    if (isUndefined(token) || isNull(token)) {
      if (!isUndefined(dto?.refreshToken)) {
        return dto.refreshToken;
      }

      throw new UnauthorizedException();
    }

    const { valid, value } = req.unsignCookie(token);

    if (!valid) {
      throw new UnauthorizedException();
    }

    return value;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, pass the dto to both endpoints:

// ...

@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
  // ...

  @Public()
  @Post('/refresh-access')
  @ApiOkResponse({
    type: AuthResponseMapper,
    description: 'Refreshes and returns the access token',
  })
  @ApiUnauthorizedResponse({
    description: 'Invalid token',
  })
  @ApiBadRequestResponse({
    description:
      'Something is invalid on the request body, or Token is invalid or expired',
  })
  public async refreshAccess(
    @Req() req: FastifyRequest,
    @Res() res: FastifyReply,
    @Body() refreshAccessDto?: RefreshAccessDto,
  ): Promise<void> {
    const token = this.refreshTokenFromReq(req, refreshAccessDto);
    const result = await this.authService.refreshTokenAccess(
      token,
      req.headers.origin,
    );
    this.saveRefreshCookie(res, result.refreshToken)
      .status(HttpStatus.OK)
      .send(AuthResponseMapper.map(result));
  }

  @Post('/logout')
  @ApiOkResponse({
    type: MessageMapper,
    description: 'The user is logged out',
  })
  @ApiBadRequestResponse({
    description: 'Something is invalid on the request body',
  })
  @ApiUnauthorizedResponse({
    description: 'Invalid token',
  })
  public async logout(
    @Req() req: FastifyRequest,
    @Res() res: FastifyReply,
    @Body() refreshAccessDto?: RefreshAccessDto,
  ): Promise<void> {
    const token = this.refreshTokenFromReq(req, refreshAccessDto);
    const message = await this.authService.logout(token);
    res
      .clearCookie(this.cookieName, { path: this.cookiePath })
      .header('Content-Type', 'application/json')
      .status(HttpStatus.OK)
      .send(message);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

External Mobile friendly API

To make our external OAuth mobile friendly we need to add an internal token endpoint for an internal code exchange.

Exchange Code Generation

First, we need to decide on the format of the code. A good, secure, and URL-friendly random string is a base62-encoded UUID, so that is what we will use. Add a static method to the Oauth2Service to generate base62 UUIDs:

// ...
import { v4 } from 'uuid';
// ...

@Injectable()
export class Oauth2Service {
  private static readonly BASE62 =
    '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  private static readonly BIG62 = BigInt(Oauth2Service.BASE62.length);
  // ...

  private static generateCode(): string {
    let num = BigInt('0x' + v4().replace(/-/g, ''));
    let code = '';

    while (num > 0) {
      const remainder = Number(num % Oauth2Service.BIG62);
      code = Oauth2Service.BASE62[remainder] + code;
      num = num / Oauth2Service.BIG62;
    }

    return code.padStart(22, '0');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

NOTE: this is a standard base 62 encoding that you can find by searching stackoverflow.

New Callback Logic

Since the callback now needs to work for both mobile and web, we need to redirect with Accept status code to the device.

Start by renaming the login method to callback on the Oauth2Service and create a callback-result.interface.ts file:

export interface ICallbackResult {
  readonly code: string;
  readonly accessToken: string;
  readonly expiresIn: number;
}
Enter fullscreen mode Exit fullscreen mode

Next, update the callback method to cache the code and return both the code and accessToken:

// ...

@Injectable()
export class Oauth2Service {
  // ...

  private static getOAuthCodeKey(code: string): string {
    return `oauth_code:${code}`;
  }

  // ...

  public async callback(
    provider: OAuthProvidersEnum,
    email: string,
    name: string,
  ): Promise<ICallbackResult> {
    // Get or create a user for the given provider
    const user = await this.usersService.findOrCreate(provider, email, name);

    // Generate and cache the code with the user email
    const code = Oauth2Service.generateCode();
    await this.commonService.throwInternalError(
      this.cacheManager.set(
        Oauth2Service.getOAuthCodeKey(code),
        user.email,
        this.jwtService.accessTime * 1000,
      ),
    );

    // Generate an access token to authenticate on the token endpoint
    const accessToken = await this.jwtService.generateToken(
      user,
      TokenTypeEnum.ACCESS,
    );
    return {
      code,
      accessToken,
      expiresIn: this.jwtService.accessTime,
    };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Lastly, update the Oauth2Controller to redirect with the correct URL Search params. Rename the loginAndRedirect method to callbackAndRedirect, and add the new business logic:

// ...

@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
  // ... 

  private async callbackAndRedirect(
    res: FastifyReply,
    provider: OAuthProvidersEnum,
    email: string,
    name: string,
  ): Promise<FastifyReply> {
    // Get the code, access token and expiration
    const { code, accessToken, expiresIn } = await this.oauth2Service.callback(
      provider,
      email,
      name,
    );

    // Build the query params
    const urlSearchParams = new URLSearchParams({
      code,
      accessToken,
      tokenType: 'Bearer',
      expiresIn: expiresIn.toString(),
    });

    // Redirect to the front-end with the encoded query params
    return res
      .status(HttpStatus.FOUND) // Note that now we use `FOUND` instead of `Redirect` as the user still needs to do an extra request
      .redirect(`${this.url}/auth/callback?${urlSearchParams.toString()}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Token Endpoint

At long last, to be able to get a refreshToken the we need to build a POST /api/auth/ext/token endpoint.

Start by creating a token method on the Oauth2Service:

// ...

@Injectable()
export class Oauth2Service {
  // ...

  public async token(code: string, userId: number): Promise<IAuthResult> {
    // Check if the code is cached
    const codeKey = Oauth2Service.getOAuthCodeKey(code);
    const email = await this.commonService.throwInternalError(
      this.cacheManager.get<string>(codeKey),
    );

    // Return 401 UNAUTHORIZED if code is invalid or expired
    if (!email) {
      throw new UnauthorizedException();
    }

    // Delete the code so it can't be used again
    await this.commonService.throwInternalError(this.cacheManager.del(codeKey));
    // Find the user by the cached email
    const user = await this.usersService.findOneByEmail(email);


    // Check if the authenticated user is the owner of the code
    if (user.id !== userId) {
      throw new UnauthorizedException();
    }

    // Return new access and refresh token
    const [accessToken, refreshToken] =
      await this.jwtService.generateAuthTokens(user);
    return {
      user,
      accessToken,
      refreshToken,
      expiresIn: this.jwtService.accessTime,
    };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Afterward, create a dto for this endpint token.dto.ts:

import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsUrl, Length } from 'class-validator';

export abstract class TokenDto {
  @ApiProperty({
    description: 'The Code to exchange for a token',
    example: '5WA0R4DVyWThKFnc73z7nT',
    minLength: 1,
    maxLength: 22,
    type: String,
  })
  @IsString()
  @Length(1, 22)
  public code: string;

  @ApiProperty({
    description: 'Redirect URI that was used to get the token',
    example: 'https://example.com/auth/callback',
    type: String,
  })
  @IsString()
  @IsUrl()
  public redirectUri: string;
}
Enter fullscreen mode Exit fullscreen mode

Finally, create the new endpoint on the Oauth2Controller:

// ...
import { TokenDto } from './dtos/token.dto';
// ...

@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
  // ...

  @Post('token')
  @ApiResponse({
    description: "Returns the user's OAuth 2 response",
    status: HttpStatus.OK,
  })
  @ApiUnauthorizedResponse({
    description: 'Code or redirectUri is invalid',
  })
  public async token(
    @CurrentUser() userId: number,
    @Body() tokenDto: TokenDto,
    @Res() res: FastifyReply,
  ): Promise<void> {
    // Check if the URI is correct
    if (tokenDto.redirectUri !== this.url + '/auth/callback') {
      throw new UnauthorizedException();
    }

    // Login the user and return the auth response
    const result = await this.oauth2Service.token(tokenDto.code, userId);
    return res
      .cookie(this.cookieName, result.refreshToken, {
        secure: !this.testing,
        httpOnly: true,
        signed: true,
        path: this.cookiePath,
        expires: new Date(Date.now() + this.refreshTime * 1000),
      })
      .header('Content-Type', 'application/json')
      .status(HttpStatus.OK)
      .send(AuthResponseMapper.map(result));
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

NOTE: if you really want to be strict you could also enforce the content-type to be application/x-www-form-urlencoded. Personally, I think allowing json as well makes the API friendlier.

Conclusion

This is the true end of my OAuth 2.0 series with NestJS, with this series you have learnt how to create a production grade OAuth 2.0 microservice using NodeJS that supports both web and mobile apps.

The full source code can be found on this repo.

About the Author

Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?

Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.

Do not miss out on any of my latest articles – follow me here on dev and LinkedIn to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!

Top comments (1)

Collapse
 
tugascript profile image
Afonso Barracha

Just noticed that I confused the accepted 202 status code, with Found 302 status code, just fixed it now