👌

Angular+Nest.js+Auth0+Heroku でシンプルな認証付きアプリを作る

2022/03/12に公開

Angular + Nest.js + Auth0 + Heroku の環境構築です。

この辺の素振りがいかに早くできるかが環境構築力にかかってる気がする。

前提

  • ng 入ってる
  • nest 入ってる
  • heroku 入ってる

環境構築

まずは、空ディレクトリを作成後 lerna init で packages ディレクトリを生成します。

mkdir my-app
cd my-app
npx lerna init

次に packages に移動して、server client 環境を構築します。

cd packages
nest new server --skip-git
ng new client --style=scss --routing=true

開発環境は、 ng serve 経由で nest.js にアクセスできるよう angular 側に proxy 設定をかけます。

まずは ルートの package.json を 以下の形で構築します。

{
  "scripts": {
    "bootstrap" : "lerna bootstrap",
    "postinstall" : "npm run bootstrap",
    "start": "lerna run start --stream",
    "build": "lerna run build --stream"
  }
}

lerna コマンドが実行できるように、 npm i lerna をルートで実行しておきましょう。
プロジェクトルートに.gitignore を追加して、 node_modules を追加するのを忘れないようにしてください。

node_modules
.env

npm run bootstrap は packages フォルダ内で一括で npm i を発行します。
チーム開発の際には 参画メンバー に共有してください。

続いて、Angular 側の設定です。

npm start は標準でサーバが立ち上がるので設定は不要です。
nest.js への Proxy 設定を通すために、
client/src/proxy.conf.json を作成します。

{
  "/api": {
    "target": "http://localhost:3000"
  }
}

client/package.json はこれを有効にするため、以下のように書き換えます。

{
  "scripts": {
    // ...
    "start": "ng serve --proxy-config src/proxy.conf.json",
    // ...
  }
}

続いて nest.js 側の設定を進めます。

server/package.json を編集して npm start で --debug を有効に

{
  "scripts": {
    // ...
    "start": "nest start --debug --watch",
    // ...
  },
}

server/src/main.ts で ポートとプレフィックスの指定を行います。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(process.env.PORT || 3000);
}
bootstrap();

server/src/app.module.ts では静的コンテンツ配信を有効にします。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '../../client/', 'dist/client'),
      exclude: ['/api'],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

依存モジュールは、 npm i @nestjs/serve-static で server のプロジェクト内で インストールしておいてください。

ここまでできれば、プロジェクトルートから npm start で開発サーバを起動できます。

http://localhost:4200 で Angular のルートが、
http://localhost:4200/api で Nest の hello world メッセージが表示できれば OK です。

デプロイ

一旦デプロイするので、Git のコミットを入れておいてください。

Heroku のアプリケーションを heroku コマンド経由で作成します。

$ heroku apps:create

アプリケーションを作成したらそのまま、push して デプロイが可能です。

$ git push heroku master 

ビルドでエラーが起きるので諸々修正していきます。

ビルドコマンド実行を可能にするために、ルートで以下のパッケージをインストールします。
コマンド中のバージョン指定は client/server それぞれでのバージョン設定と合わせてください。

$ npm i -D @angular/cli@12 @angular-devkit/build-angular@12 @nestjs/cli@8

Procfile を以下の内容で作成します。

web: node packages/server/dist/main

ここまでの内容を Heroku に再度 push して deploy できれば成功です。

heroku open でアプリケーションを開いて Angular と Nest の動作を確認してください。

Auth0 の設定

$ heroku addons:add auth0
$ heroku config -s | grep 'AUTH0_CLIENT_ID\|AUTH0_CLIENT_SECRET\|AUTH0_DOMAIN' | tee -a .env
$ heroku addons:open auth0

Auth0 の設定画面で Applications から Default Appを選択
Machine to Machine Applications を選択

APIs を選択して、Machine to Machine Applications を選択して、
Default App を有効に。
設定の詳細で、Scope を create:users に。

Nest.js の設定 - 会員登録 API の作成

server 側を実装していきます。

必要モジュールをインストールしていきましょう。

$ npm i @nestjs/config auth0

packages/server/src/app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '../../client/', 'dist/client'),
      exclude: ['/api'],
    }),
    ConfigModule.forRoot({
      envFilePath: __dirname+'/../../../.env',
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

packages/server/src/app.controller.ts

import {Body, Controller, Get, Post} from '@nestjs/common';
import { AppService } from './app.service';

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

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

  @Post("register")
  async register(@Body() body): Promise<any> {
    const result = await this.appService.register("info@chatbox-inc.com","Secret12345","tomohiro goto")
    console.log(result)
    return {
      "hello": "hello"
    };
  }
}

packages/server/src/app.service.ts

import {Injectable, Scope} from '@nestjs/common';
import { ManagementClient } from "auth0"

@Injectable({
  scope:Scope.REQUEST
})
export class AppService {

  private manageClient;

  constructor() {
    console.log("[AppService] service created")
    const cred = {
      domain: process.env.AUTH0_DOMAIN,
      clientId: process.env.AUTH0_CLIENT_ID,
      clientSecret: process.env.AUTH0_CLIENT_SECRET,
      scope: 'create:users'
    }
    this.manageClient = new ManagementClient(cred)
  }

  getHello(): string {
    return 'Hello World!';
  }

  async register(email: string, password: string, name: string): Promise<any> {
    return await this.manageClient.createUser({
      connection: "Username-Password-Authentication",
      email,
      password,
      name,
      email_verified: true,
    })
  }
}
$ curl -X POST http://localhost:4200/api/register

Auth0 の画面で User Management -> Users からユーザ登録を確認

結合が確認できたら、body の解釈を入れる。

import {Body, Controller, Get, Post} from '@nestjs/common';
import { AppService } from './app.service';

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

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

  @Post("register")
  async register(@Body() body): Promise<any> {
    console.log("body",body)
    console.log("name",body.name)
    const result = await this.appService.register(body.email,body.password,body.name)
    return {
      "hello": "hello"
    };
  }
}
$ curl -X POST http://localhost:4200/api/register -d '{"name":"Taro","email":"info2@chatbox-inc.com", "password":"Secret12345"}' -H Content-type:"application/json"

Angular の画面構成

画面を作るために、まずは tailwind のインストールから始めます。

https://tailwindcss.com/docs/guides/angular

併せて、form のプラグインを導入します。

https://github.com/tailwindlabs/tailwindcss-forms

インストール時にエラーが出る場合は、
以下のような形でバージョンを指定すると良いでしょう。

npm install -D @tailwindcss/forms@0.3

トップ、会員登録、mypage の3画面を構築します。

$ ng g component top
$ ng g component join
$ ng g component mypage

テンプレートはそれぞれ以下のような形になります。

app.component.html

<div class="w-96 mx-auto">
  <div class="border-b-2 p-2">
    <a class="font-bold text-gray-400" routerLink="/"> Angular Auth0 </a>
  </div>
  <div class="px-2">
    <router-outlet></router-outlet>
  </div>
</div>

top.component.html

<div class="my-5">ようこそ、アプリケーションへ</div>

<div class="mb-5">
  <a routerLink="/mypage" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full">
    ログイン
  </a>
</div>
<div class="mb-5">
  <a routerLink="/join" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full">
    会員登録
  </a>
</div>

join.component.html

<div class="my-5">さぁ、アカウントを作成しましょう</div>

<div class="mb-5">
  <div class="mb-3">
    <label class="block">お名前</label>
    <input type="text" >
  </div>
  <div class="mb-3">
    <label class="block">email</label>
    <input type="email" >
  </div>
  <div class="mb-3">
    <label class="block">パスワード</label>
    <input type="password">
  </div>
  <div class="mb-3">
    <a routerLink="/" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full">
      会員登録
    </a>
  </div>
</div>

mypage.component.html

<div class="my-5">ようこそ、マイページへ</div>

<div class="mb-5">
  あなたの名前は Tom Brown ですね
</div>

画面遷移して正しく表示できていれば画面の準備はOKです。

Angular で認証設定

Auth0 のダッシュボードで Applications から Angular 用の新しい Application 設定を作成します。

Single Page Application で Application を作成して、Domain/ClientId を控えておきましょう。
(Nest.js で利用した アプリケーションの認証情報は流用してはいけません!)

アプリケーションの中で以下の設定を入れておく必要があります。

  • Allowed Callback URLs : mypage のアドレス
  • Allowed Logout URLs : top のアドレス
  • Allowed Web Origins : アプリケーションのドメイン(Origin)

Angular の実装に移りましょう。

必要なモジュールをインストールします。

$ npm i @auth0/auth0-angular

app.module.ts に以下のような形でセットアップします。

// ...
import {AuthModule} from "@auth0/auth0-angular";

@NgModule({
  // ...  
  imports: [
    BrowserModule,
    AuthModule.forRoot({
      domain: '取得した domain',
      clientId: '取得した clientId',
      redirectUri: window.location.origin + "/mypage"
    }),
    AppRoutingModule,
  ],
  // ...  
})
export class AppModule { }

トップページのコンポーネントを以下のように書き換えて、ログインを実装します。

top.component.html

<div class="mb-5">
  <a (click)="login()" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full">
    ログイン
  </a>
</div>

top.component.ts

import { Component, OnInit } from '@angular/core';
import {AuthService} from "@auth0/auth0-angular";

@Component({ /* ... */})
export class TopComponent implements OnInit {
  // ...
  login(){
    this.auth.loginWithRedirect({
      appState: { target: '/mypage' }
    }).subscribe((r)=>{
      console.log("logined",r,location.origin)
    })
  }
}

ログイン先の mypage コンポーネントでユーザ情報を表示できるように書き換えます。

mypage.component.html

<div class="my-5">ようこそ、マイページへ</div>

<div *ngIf="auth.user$ | async as user">
  あなたの名前は {{user.name}} ですね
</div>

mypage.component.ts

import { Component, OnInit } from '@angular/core';
import {AuthService} from "@auth0/auth0-angular";

@Component({
  selector: 'app-mypage',
  templateUrl: './mypage.component.html',
  styleUrls: ['./mypage.component.scss']
})
export class MypageComponent implements OnInit {

  constructor(public auth: AuthService) { }

  ngOnInit(): void {
  }

}

ここまででログインのデバッグが可能なので、実際にログインして動作確認をしておきましょう。
ログイン情報は curl で登録したユーザ情報を利用しましょう。

ログイン時に表示される consent screen は localhost での挙動時には無効化することができません。
management API で consent screen の Skip 設定を ON にしておけば、
Heroku などのドメイン環境では content screen を Skip できます。

ログイン動作が動けば、次はログアウトの実装です。

app.component を編集してヘッダにログアウト導線を追加します。

app.component.html

<div class="w-96 mx-auto">
  <div class="border-b-2 p-2 flex justify-between">
    <a class="font-bold text-gray-400" routerLink="/"> Angular Auth0 </a>
    <a class="font-bold text-gray-400" (click)="logout()">logout</a>
  </div>
  <div class="px-2">
    <router-outlet></router-outlet>
  </div>
</div>

app.component.ts

import { Component } from '@angular/core';
import {AuthService} from "@auth0/auth0-angular";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'client';

  constructor(private auth:AuthService) {
  }

  logout(){
    this.auth.logout({
      returnTo: `${document.location.origin}/top`
    })
  }
}

ログアウトの挙動確認ができたら、mypage のルートを Guard で守っておきましょう。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {JoinComponent} from "./join/join.component";
import {MypageComponent} from "./mypage/mypage.component";
import {TopComponent} from "./top/top.component";
import {AuthGuard} from "@auth0/auth0-angular";

const routes: Routes = [
  {path: "",redirectTo:"top", pathMatch:"full"},
  {path: "top",component: TopComponent},
  {path: "join",component: JoinComponent},
  {path: "mypage",component: MypageComponent, canActivate:[AuthGuard]},
];

@NgModule({
  imports: [RouterModule.forRoot(routes,{ enableTracing: true })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

API の発行 - Angular Side

今の実装では、 Auth0 から Profile を取っていますが、
Nest.js の API から Profile を取得できるように変更してみましょう。

まずは Angular の側から修正を始めます。

mypage.component を以下のような形で修正して、
認証が完了したタイミングで profile の API を発行するようにします。

import { Component, OnInit } from '@angular/core';
import {AuthService} from "@auth0/auth0-angular";
import {HttpClient} from "@angular/common/http";
import {map, switchMap} from "rxjs/operators";
import {Observable} from "rxjs";

@Component({
  selector: 'app-mypage',
  templateUrl: './mypage.component.html',
  styleUrls: ['./mypage.component.scss']
})
export class MypageComponent implements OnInit {

  public apiUser$:Observable<any>;

  constructor(public auth: AuthService, private client: HttpClient) {
    this.apiUser$ = this.auth.user$.pipe(
      switchMap(()=>{
        return this.client.get("/api/profile")
      }),
      map((r:any) => r.user)
    )
  }

  ngOnInit(): void {
    this.apiUser$.subscribe(r=>{
      console.log(r)
    })
  }
}

API の実装はまだなのでエラーが出ますが、
そもそも API 発行時に bearer token が無いため、
API は認証を処理できません。

@auth0/auth0-angular には http client の intercepter があるので、
これを利用して自動的に Bearer token がつくようにしましょう。

// ...
import {AuthHttpInterceptor, AuthModule} from "@auth0/auth0-angular";
import {HTTP_INTERCEPTORS, HttpClientModule} from "@angular/common/http";

@NgModule({
  declarations: [ /* ... */ ],
  imports: [
    BrowserModule,
    HttpClientModule,
    AuthModule.forRoot({
      domain: 'royal-night-6877.us.auth0.com',
      clientId: '6DSRWil86qd60cq5jxIcaiSNqMHBeZCp',
      redirectUri: window.location.origin + "/mypage",
      audience: "https://royal-night-6877.us.auth0.com/api/v2/",
      scope: 'read:current_user',
      httpInterceptor: {
        allowedList: [
          '/api/*',
          {
            uri: '/api/guest/*',
            allowAnonymous: true,
          },

        ]
      }
    }),
    AppRoutingModule,
  ],
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthHttpInterceptor, multi: true }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

API の発行 - Nest.js 側

Bearer token が付与できたので、API を実装します。

$ npm i @nestjs/passport jwks-rsa passport-jwt

server/src/jwt.strategy.ts を作成します。

// import先が'passport-local'では無い事に注意!
import { ExtractJwt, Strategy as BaseJwtStrategy } from 'passport-jwt';

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {passportJwtSecret} from "jwks-rsa";


/**
 * @description JWTの認証処理を行うクラス
 */
@Injectable()
export class JwtStrategy extends PassportStrategy(BaseJwtStrategy) {
    constructor() {
        console.log("here")
        super({
            secretOrKeyProvider: passportJwtSecret({
                cache: true,
                rateLimit: true,
                jwksRequestsPerMinute: 5,
                jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`,
            }),
            // Authorization bearerからトークンを読み込む関数を返す
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            audience: `https://${process.env.AUTH0_DOMAIN}/api/v2/`,
            issuer: `https://${process.env.AUTH0_DOMAIN}/`,
            algorithms: ['RS256'],
        });
    }

    // ここでPayloadを使ったバリデーション処理を実行できる
    // Payloadは、AuthService.login()で定義した値
    async validate(payload): Promise<any> {
        return { ...payload };
    }
}

作成した strategy を PassportModuel とともに、app.module.ts に読み込みます。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

import {PassportModule} from "@nestjs/passport";
import {JwtStrategy} from "./jwt.strategy";

@Module({
  imports: [
    // ...
    PassportModule.register({ defaultStrategy: 'jwt' })
  ],
  controllers: [AppController],
  providers: [AppService,JwtStrategy],
})
export class AppModule {}

app.controller.ts の実装は以下のような形になります。

import {Body, Controller, Get, Post, Request, UseGuards} from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class AppController {

  // ...
  
  @UseGuards(AuthGuard("jwt"))
  @Get("profile")
  async profile(@Request() req){
    const result = await this.appService.profile(req.user.sub as string)
    return {
      hoge:"piyo",
      payload: req.user, // JWT をパースして得られるデータ
      user:result // ManagementAPI の結果
    }
  }
}

API を発行している appService.profile の実装は以下のような形です。

import {Injectable, Scope} from '@nestjs/common';
import {AuthenticationClient, ManagementClient} from "auth0"

@Injectable({
  scope:Scope.REQUEST
})
export class AppService {

  // ... 

  async profile(id: string): Promise<any> {
    return await this.manageClient.getUser({
      id
    })
  }
}

API 発行のために、 management api で read:users を有効化するのを忘れないようにしてください。

追記

Join フォーム結局使ってない

参考

https://auth0.com/docs/quickstart/spa/angular/01-login
https://auth0.com/docs/libraries/auth0-angular-spa
https://auth0.github.io/auth0-spa-js/classes/auth0client.html#loginwithredirect

https://zenn.dev/uttk/articles/9095a28be1bf5d
https://github.com/jajaperson/nestjs-auth0

Todo

Discussion