【NestJS】Hasura・NestJSをAuth0で認証処理を導入する

# 概要

この記事では`NestJS`のAPIエンドポイントと`Hasura`に対する認証を担うプラットフォームとして`Auth0`を採用した際の導入方法についてまとめています

https://auth0.com/jp/authentication

<details><summary>HasuraをAuth0で保護する方法</summary>

# `Hasura`を`Auth0`で保護する方法

基本的には`hasura`のドキュメント・チュートリアルが充実しているためその通りに進めれば設定は完了します

https://hasura.io/learn/ja/graphql/hasura/introduction/

`Docker`で環境構築をされている場合は`AUth0`の公開鍵を発行して環境変数に設定する必要があるので注意が必要です

https://hub.docker.com/r/hasura/graphql-engine

## 環境変数に公開鍵を設定する

### 公開鍵の発行

こちらで説明されている通り公開鍵を発行してください

https://hasura.io/learn/ja/graphql/hasura/authentication/3-setup-env-vars-hasura/

### 環境変数の設定

```yaml:docker-compose.yaml
version: '3.6'
services:
  postgres:
    image: postgres
    restart: always
    volumes:
    - db_data:/var/lib/postgresql/data
  graphql-engine:
    image: hasura/graphql-engine:v1.0.0-beta.6
    ports:
    - "8080:8080"
    depends_on:
    - "postgres"
    restart: always
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:@postgres:5432/postgres
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
      ## uncomment next line to set an admin secret
      # HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
      # HASURA_GRAPHQL_JWT_SECRET: '発行した公開鍵を定義します'
volumes:
  db_data:
```

</details>


<details><summary>NestJSをAuth0で保護する</summary>

# `NestJS`のAPIエンドポイントの保護

基本的に`NestJS`では`Hasura`で吸収できない複雑な処理のみを担ってもらいます。
Hasuraを経由する場合は前述の`Hasura`の保護でセキュアに保つことができます。
そこで`NestJS`側のエンドポイントを直接叩かれないように同様に`Auth0`で保護していきたいと思います。

## 認証で利用する値を環境変数に設定

`.env`に直接書くより`GCP`の`Cloud Run`の`Secret`で環境変数を定義しておく方がよさそうですが、一旦こちらに定義しています

```:.env
# DomainName 
AUTH0_ISSUER_URL="https://xxxx.auth0.com/"

# Identifier
AUTH0_AUDIENCE="xxxxx"
```

## リクエスト前の`Guard`機能を実装

特定のリクエストを本当に通して良いのかを検証してくれるように実装していきます

> 実行時に存在する特定の条件 (パーミッション、ロール、ACL など) に応じて、与えられたリクエストがルートハンドラによって処理されるかどうかを決定します。これはしばしば認可 (authorization) と呼ばれます。

https://docs.nestjs.com/guards

### `Guard`を作成する

https://docs.nestjs.com/cli/usages

```bash
# NestJS CLIで作成します
nest g gu auth/auth-guard
```

### `Guard`を定義する

```ts:app/src/common/guard/auth/auth-guard.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common'
import { GqlContextType } from '@nestjs/graphql'
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'
import { Reflector } from '@nestjs/core'
import { expressjwt, GetVerificationKey } from 'express-jwt'
import { expressJwtSecret } from 'jwks-rsa'
import { ConfigService } from '@nestjs/config'
import { promisify } from 'util'

@Injectable()
export class AuthGuard implements CanActivate {
  private readonly AUTH0_AUDIENCE: string
  private readonly AUTH0_ISSUER_URL: string

  constructor(
    @InjectPinoLogger(AuthGuard.name) private readonly logger: PinoLogger,
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
  ) {
    this.AUTH0_AUDIENCE = this.configService.get('AUTH0_AUDIENCE')
    this.AUTH0_ISSUER_URL = this.configService.get('AUTH0_ISSUER_URL')
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    if (context.getType<GqlContextType>() === 'graphql') {
      return true
    }

    // Auth0に対してJwtTokenの整合性を確認
    const checkJwtToken = await promisify(
      expressjwt({
        secret: expressJwtSecret({
          cache: true,
          rateLimit: true,
          jwksRequestsPerMinute: 5,
          jwksUri: `${process.env.AUTH0_ISSUER_URL}.well-known/jwks.json`,
        }) as GetVerificationKey,
        audience: this.AUTH0_AUDIENCE,
        issuer: this.AUTH0_ISSUER_URL,
        algorithms: ['RS256'],
      }),
    )

    try {
      await checkJwtToken(
        context.switchToHttp().getRequest(),
        context.switchToHttp().getResponse(),
      )
      return true
    } catch (e: unknown) {
      throw new UnauthorizedException(e)
    }
  }
}
```

## `API`への保護を有効にします

```ts:app/src/app.module.ts
import { HttpAdapterHost, NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { Logger } from 'nestjs-pino'
import { AllExceptionsFilter } from './common/filter/all-exceptions.filter'

async function bootstrap() {
  // nestjs-pino https://github.com/iamolegga/nestjs-pino
  const app = await NestFactory.create(AppModule, { bufferLogs: true })
  app.useLogger(app.get(Logger))

  const adapterHost = app.get(HttpAdapterHost)
  const httpAdapter = adapterHost.httpAdapter
  const instance = httpAdapter.getInstance()
  app.useGlobalFilters(new AllExceptionsFilter(instance))

  app.enableCors({
    origin: '*',
    allowedHeaders:
      'Origin, X-Requested-With, Content-Type, Accept, Authorization',
  })

  await app.listen(3000)
}
bootstrap()
```

## 検証

`@UseGuards(AuthGuard)`で保護しているエンドポイントに対して`id_token`付きでリクエストを投げていきます

```ts:app/src/app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common'
import { AppService } from './app.service'
import { AuthGuard } from './common/guard/auth/auth-guard.guard'

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello()
  }

  @UseGuards(AuthGuard)
  @Get('/private')
  async private() {
    return { message: '成功したお' }
  }
}
```

## `Auth0` からトークン取得するスクリプト

こちらを参考にさせていただきました🙇‍♂️

以下のスクリプトを実行し成功すると`id_token`が発行されるのでコピーしておく

https://dev.classmethod.jp/articles/auth0-nestjs-backend-sample/

```sh
#!/usr/bin/env bash

auth_url=https://xxx.auth0.com
client_id=xxxx
client_secret=xxxx
username="Auth0に登録されているアカウント"
password="アカウントのパスワード"

echo "success🎁 id_tokenを使用してください"

curl -s --request POST \
  --url ${auth_url}/oauth/token \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data grant_type=password \
  --data username=xxxx \
  --data password=xxxx \
  --data client_id=xxxx \
  --data client_secret=xxxx \
echo "\n"
```

## リクエスト

正常に`id_token`を渡すとレスポンスが返ってくることを確認できます

試しに`id_token`の一部を消して再度リクエストを行うと認証に失敗します

```
curl -i -X GET 'http://localhost:3000/private' -H 'Authorization: Bearer id_token'
HTTP/1.1 200 OK X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
charset=utf-8
Content-Length: 64
Connection: keep-alive
Keep-Alive: timeout=5
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 34
ETag: W/"22-hShrnoFIPYGoHz/Bm72qqTZK7ss"
Date: Fri, 19 Aug 2022 16:25:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"成功したお"}
```

</details>

# おわりに

今回は直近で開発した内容を忘れないように備忘録として残しました。
これから開発される方々のお役に立てば幸いです