우리 서비스는 jwt 형식의 access token을 통해 사용자를 인증한다.

따라서 대부분 api는 이 access token이 유효한지 인증하고, 유효하지 않다면 예외 응답을, 유효하다면 해당 토큰을 파싱하여 필요한 정보를 라우트 핸들러로 넘긴다.

따라서 대부분 api들에서 사용할 nestjs pipe를 만들면 좋을 것 같다고 생각하여 구현해보기 시작했다.

우선 데코레이터에 대한 기본적인 이해가 필요해서 찾아서 공부해봤다. JS decorator의 기본적인 구현 방법과 설명은 링크의 글에서 공부할 수 있었다. decorator 패턴이라는 디자인 패턴 측면에선 링크의 글에서 간단하게 공부할 수 있었다.

결국 중요한 건 JS에서 decorator는 특정 class 혹은 class property에 붙여서 사용할 수 있고, decorator는 적용된 class의 constructor 혹은 class property를 인자로 받아서 임의의 추가 작업을 진행할 수 있단 것이다.

다음으로는 custom pipe의 transform 함수 첫번째 인자에 대한 이해도 필요했다. 처음엔 pipe가 라우트 핸들러 이전에 위치해서 ‘전체 Request’ → ‘Pipe(transform 함수)’ → ‘Route Handler’ 순으로 요청이 pipe를 지나며 처리되는 줄 알았다. 즉, transform 함수의 첫 인자로 무조건 전체 요청 값이 들어와서 처리되는 줄 알았다. 근데 공식문서에 적힌 글을 자세히 읽어보니, Route Handler의 인자로 들어오는 값이 Pipe의 transform 함수의 첫번째 인자로 들어가 처리되는 것이었다. 그리고 transform 함수가 반환하는 값을 Route Handler의 인자로 넘어간다.

예를들어 아래와 같이 파이프를 적용한다면, 요청의 쿼리 스트링 중 ‘id’ 라는 필드의 값이 ParseIntPipe라는 파이프 내의 transform 함수의 첫번째 인자로 들어간다. 이후 transform 함수의 반환 값이 id 인자에 들어가는 것이다. 따라서 id 인자의 타입은 transform 함수가 반환하는 타입으로 맞춰주면 된다.

@Get(':id')
  findOne(@Param('id', new ParseIntPipe()) id: number) {
    // ...
  }

문제 상황

여기까지 사전에 공부했던 것을 매우 간단하게 정리한 것이고, 아래는 실제로 우리 서비스에서 사용하는 액세스 토큰을 검증하기 위한 JWT Validation Pipe을 구현해본 것이다. 클라이언트는 매번 Authorization 헤더에 토큰을 담아서 주므로 헤더에 접근할 필요가 있었다. 그래서 아래와 같이 코드를 작성해 봤다.

// jwt.vaidation.pipe.ts

// ...import문들...

@Injectable()
export class JwtValidationPipe implements PipeTransform {
  transform(req: Request) {
    const token = req.headers['authorization']?.split(' ')[1];

    if (!token)
      // 토큰 없음!
      console.log('여기서 요청에 토큰 데이터가 없다고 응답 보내기');
    // 1. signature 유효한지 검사
    const headerStr = extractHeaderJWTstr(token);
    const payloadStr = extractPayloadJWTstr(token);
    const signatureStr = extractSignatureJWTstr(token);
    if (
      signatureStr !==
      hashHMACSHA256(headerStr + '.' + payloadStr, process.env.JWT_SECRET_KEY)
    )
      throw new CustomResponse(ECustomCode.JWT03);

    // 1-1. payload 추출
    const payload = extractPayloadJWT(token);

    // 2. issuer가 일치하는지 검사 (아직은 issuer만 확인)
    const issuer = process.env.LAYOVER_PUBLIC_IP;
    if (payload.iss != issuer) throw new CustomResponse(ECustomCode.JWT04);

    // 3. exp를 지났는지 검사
    if (Math.floor(Date.now() / 1000) > payload.exp)
      throw new CustomResponse(ECustomCode.JWT02);

    // jwt 페이로드를 객체로 변환하여 넘겨줌
    return extractPayloadJWT(token);
  }
}
// oauth.controller.ts

// ...import문들...

@Controller('auth')
export class OauthController {
  // ...
  @Post('refresh-token')
  @UsePipes(new JwtValidationPipe())
  async renewTokens(@Req() payload) {
    console.log('payload: ', payload);

    // 새로운 토큰을 생성하고 이를 반환함
    const { accessJWT, refreshJWT } =
      await this.oauthService.generateAccessRefreshTokens(payload.memberHash);

    // return access token and refresh token
    throw new CustomResponse(ECustomCode.SUCCESS, {
      accessToken: accessJWT,
      refreshToken: refreshJWT,
    });
  }
}

근데 잘 작동하지 않았다. renewTokens 함수의 첫번째 줄에서 payload를 찍어봤는데, transform의 반환값이 아닌 Request 객체 전체가 출력됐다. 마치 인자에 파이프가 적용되지 않는 것 같았다.

@Post('refresh-token')
  @UsePipes(new JwtValidationPipe())
  async renewTokens(@Headers('Authorization') payload) {
    console.log('payload: ', payload);

    // 새로운 토큰을 생성하고 이를 반환함
    const { accessJWT, refreshJWT } =
      await this.oauthService.generateAccessRefreshTokens(payload.memberHash);

    // return access token and refresh token
    throw new CustomResponse(ECustomCode.SUCCESS, {
      accessToken: accessJWT,
      refreshToken: refreshJWT,
    });
  }