NestJSで作成したバックエンドAPIをAuth0で認証/認可

この記事は、NestJSで作成したバックエンドAPIをAuth0で認証/認可する方法をまとめています。 SPAのフロントエンドがまだ作成されていない状態で、バックエンドAPIを先行して作成する場合を想定しています。
2022.03.27

はじめに

この記事は、NestJSで作成したバックエンドAPIをAuth0で認証/認可する方法をまとめています。
SPAのフロントエンドがまだ作成されていない状態で、バックエンドAPIを先行して作成する場合を想定しています。

注意点

本記事内で使用するJWTやAuth0は、使い方によっては脆弱性を生む原因となります。
この記事はあくまで参考程度にとどめて頂き、実際のプロジェクトで採用する場合は適切なセキュリティ対策を行ってください。

動作環境

今回使用した動作環境は以下のとおりです。

  • PC : Mac M1(Apple Silicon)チップ
  • OS : macOS Big Sir
  • Node.js : v16.13.2
  • NestJS : Version8系

サンプルプロジェクト

このサンプルプロジェクトは簡単なREST APIです。誰でもアクセス可能な公開APIと認証/認可が必要なプライベートAPIの2つのAPIがあります。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。

プロジェクト構成

一部抜粋

auth0-backend-sample/  ルートディレクトリ
  ┣ src
  ┃ ┣ authz Auth0での認証/認可のためのモジュール群
  ┃ ┃ ┣ authz.module.ts
  ┃ ┃ ┗ jwt.strategy.ts 
  ┃ ┣ main.ts 
  ┃ ┣ sample.controller.ts 
  ┃ ┗ sample.module.ts
  ┣ tool/
  ┃  ┣ get_auth0_token.sh  Auth0テナントに登録済のユーザーのトークンを取得するスクリプト
  ┃  ┗ regist_auth0_user.sh Auth0テナントにユーザーを新規登録するスクリプト
  ┗ .env 環境変数定義ファイル

Auth0での認証/認可

まずAuth0での認証/認可部分を実装していきます。
こちらの公式ブログを参考にさせて頂きました。

必要なライブラリのインストール

以下のライブラリが必要です。新規でプロジェクトを作成する際はnpm iまたはyarn addで追加してください。

  • passport Node.jsの認証ライブラリ
  • @nestjs/passport NestJS用のpassportライブラリ
  • passport-jwt  JWTの検証のためのライブラリ
  • jwks-rsa Auth0テナントのJWKS(JSON Web Key Set)エンドポイントからRSA署名鍵を取得するためのライブラリ
  • dotenv .envファイルに定義された環境変数を取得するためのライブラリ

src/authz/jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import * as dotenv from 'dotenv';

dotenv.config();

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${process.env.AUTH0_ISSUER_URL}.well-known/jwks.json`,
      }),

      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      audience: process.env.AUTH0_AUDIENCE,
      issuer: `${process.env.AUTH0_ISSUER_URL}`,
      algorithms: ['RS256'],
    });
  }

  validate(payload: unknown): unknown {
    return payload;
  }
}

抽象クラスPassportStrategyを拡張することで、認証戦略が設定されます。
jwksUriのJWKSエンドポイントからRSA署名鍵を取得しJWTの検証を行います。
cache: trueとすることで、JWKSエンドポイントへの過剰なリクエストを防ぐために、取得したRSA署名鍵のキャッシュが有効になります。
署名キーのkidごとにキャッシュされ、次に同様のkidが要求されたときにキャッシュが利用されます。
デフォルトのキャッシュ時間は10分で、chachMaxAgeを設定することで変更可能です。

また、攻撃者が多数のランダムなkidのJWTを送信する攻撃を防ぐために、1分間でJWKSエンドポイントに対してリクエストを送る回数を制限しています。
jwksRequestsPerMinuteの値で変更可能で、上記設定だと1分間に5回までのリクエスト送信に制限されています。

audienceissuerはJWTの検証に使用するので、環境変数から値を取得します。

validateメソッドのpayloadにはJWTのペイロードが格納されています。
validateメソッドを拡張し、JWTの検証以外の検証ロジックを実装することも可能です。

src/authz/authz.module.ts

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
  providers: [JwtStrategy],
  exports: [PassportModule],
})
export class AuthzModule {}

passortのNestJSラッパーのPassportModuleをインポートし、JWTの認証戦略として上記で作成したjwt.strategy.tsのロジックを指定しています。
この設定を行うことで@UseGuards()のデコレータを使用した、APIの保護が可能になります。

APIの保護の有効化

src/main.ts

import { NestFactory } from '@nestjs/core';
import { SampleModule } from './sample.module';

async function bootstrap() {
  const app = await NestFactory.create(SampleModule);

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

  await app.listen(8080);
}
bootstrap();

CORSの設定を有効化しています。
SPAから呼び出す際はAuthorizationヘッダーを許可する必要があります。

src/sample.module.ts

import { Module } from '@nestjs/common';
import { AuthzModule } from './authz/authz.module';
import {SampleController} from "./sample.controller";

@Module({
  imports: [AuthzModule],
  controllers: [SampleController],
})
export class SampleModule {}

コントローラで認証/認可モジュールを利用できるようインポートしています。

src/sample.controller.ts

import {Controller, Get, UseGuards, Request} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class SampleController {

  @Get('/public')
  async public(@Request() req) {
    return {message: 'public api'}
  }

  @UseGuards(AuthGuard('jwt'))
  @Get('/private')
  async private(
      @Request() req,
  ) {
    return {message: 'private api', sub: req.user.sub}
  }

}

/publicのAPIは認証なしに呼び出し可能です。

/privateAPIは@UseGuards(AuthGuard('jwt'))を指定することで保護されています。
AuthorizationヘッダーにBearer トークンを指定することで呼び出すことが可能になります。

JWTトークンのペイロードには、@Requestデコレータで指定した引数のuserフィールドからアクセス可能です。 上記ではreq.user.subとすることで、Auth0で発行したユーザーのsubを返却しています。

Auth0側の設定

Auth0 APIでのユーザー登録、トークンの取得が可能になるようにいくつかの設定を行います。
Auth0のアカウント作成やアプリケーションの登録についてはこちらの記事をご参照ください。

  1. SPA用に作成したアプリケーション設定を修正します。

  2. Settingタブを開いてください。またDomainClient IDClient Secretの値は後ほど使用するのでメモをお願いします。

  3. Settingタブそのまま下までスクロールして、Advanced Settingを開いてください。

  4. Grant Typesタブに切り替えてください。

  5. Passwordにチェックをいれて、Save Changesボタンを押してください。

  6. 続いて、サイドバーのSettingsからTenant Settingsに移動しGeneralタブを開いてください。

  7. そのままスクロールし、API Authorization SettingsのDefault DirectoryUsername-Password-Authenticationと入力し、Saveボタンを押してください。Username-Password-AuthenticationはAuth0にデフォルトで用意されているユーザーの認証DBになります。

Auth0側の設定は以上になります。

動作確認

  1. 環境変数の設定。.envファイルにメモしておいたDomainClient IDを設定してください。

    一部抜粋

    AUTH0_ISSUER_URL=https://<Domain>/
    AUTH0_AUDIENCE=<Client ID>

  2. バックエンドAPIの起動。下記コマンドを実行してください。

    % yarn install or npm install
    % yarn start or npm run start

下記コマンドで公開APIを呼び出すと正常なレスポンスが返却されます。

% curl -i -X GET 'http://localhost:8080/public'
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 24
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"public api"}

下記コマンドでプライベートAPIを呼び出すとステータスコード401が返却されます。

% curl -i -X GET 'http://localhost:8080/private'
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 43
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":401,"message":"Unauthorized"}

3. Auth0に新規ユーザー登録を行います。 tool/regist_auth0_user.shのファイルにモしておいたDomainClient IDを設定し、登録したいユーザーのemailusernamepasswordを指定してください。
ここではサンプルとしてemail=alice_test@example.comusername=alice_testpassword=Alicetest@1としています。

tool/regist_auth0_user.sh

#!/usr/bin/env bash

auth_url=https://<Domain>
client_id=<Client ID>
email=alice_test@example.com
username=alice_test
password=Alicetest@1

echo "Register user"
curl -X POST "${auth_url}/dbconnections/signup" \
  -H 'Content-Type: application/json' \
  -d "{\"client_id\": \"${client_id}\",\"email\":\"${email}\",\"password\": \"${password}\",\"connection\": \"Username-Password-Authentication\",\"username\":\"${username}\",\"name\":\"${username}\",\"nickname\": \"${username}\"}"
echo "\n"

以下コマンドを実行するとAuth0にユーザーが登録されます。

% cd ./tool
% ./regist_auth0_user.sh
Register user
{"name":"alice_test","nickname":"alice_test","_id":"xxxxxxxxxxxxxxxxxxxxxx","email_verified":false,"email":"alice_test@example.com"}\n

Auth0のダッシュボードからサイドバーのUser Managementを開きUsersをクリックしてください。先程登録したユーザー情報が確認できます。

4. 登録したユーザーのトークンを取得します。 tool/regist_auth0_user.shのファイルにモしておいたDomainClient IDClient Secretを設定し、先程登録したユーザーのusername(メールアドレス)、passwordを指定してください。
ここではサンプルとしてusername=alice_test@example.compassword=Alicetest@1としています。

tool/get_auth0_token.sh

#!/usr/bin/env bash

auth_url=https://<Domain>
client_id=<Client ID>
client_secret=<Client Secret>
username=alice_test@example.com
password=Alicetest@1

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

以下コマンドを実行するとトークンが取得できます。

% cd ./tool
% ./get_auth0_token.sh
Get Token
{
  "access_token": "<ACCESS_TOKEN>",
  "id_token": "<ID_TOKEN>",
  "scope": "openid profile email address phone",
  "expires_in": 86400,
  "token_type": "Bearer"
}
\n

5. 取得したIDトークンAuthorizationヘッダーにBearerトークンとし設定し以下コマンドを実行すると、プライベートAPIは正常なレスポンスを返却します。

% curl -i -X GET 'http://localhost:8080/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

{"message":"private api","sub":"auth0|xxxxxxxxxxxxxxxxxxxxx"}

最後に

NestJSは認証ライブラリが充実していたので比較的簡単に実装することができました。
繰り返しになりますが、本記事はあくまで参考程度にとどめて頂き、実際のプロジェクトでは適切なセキュリティ対策を行うようにしてください。