Auth0 で SPA から Google ログイン – Angular で Google のメールアドレスを取得する

2019.10.03

サーバーレスアプリケーションを開発する中で、認証の話に触れることが多くなりました。Amazon Cognitoを使うことも多かったのですが、お客様によってはAWSアカウントを複数使って事業を展開するケースもあり、SaaS を使う機運が高まっていると感じます。特に認証周りは、事業側としては簡略化したい筆頭です。

今回は 認証サービスに Auth0 を使ってみます。認証という単語にはいろいろな要素が詰め込まれていますので、絞ります。エンドユーザーが Angular SPA にアクセスし、Google ログインを行ってSPA にメールアドレスを渡す というユースケースを実装しましょう。このユースケースには次の要素が入ってます:

  • Angular SPA から Auth0 のユニバーサルログインを呼び出す
  • ユニバーサルログインで Google の OAuth2 アクセストークンを取得 する
  • ログインした Google のメールアドレスを SPA で表示する

作業の流れ

忙しい人向け。次のような作業を行い、最終的にログインユーザーのメールアドレスを取得します。

  1. Auth0にログインしてテナントを作成します。
  2. Auth0 の Applications を Single Page Application として作成します。
  3. Auth0 の Applications > Settings でAngular のローカルサーバー http://localhost:4200 をドメインとして設定します。
  4. Auth0 の Connections > Social で Google を追加します。Client ID と Client Secret が必要です。
    1. Google Cloud Pratform で API とサービス > OAuth 同意画面 を開き、承認済みドメインとして auth0.com を設定します。
    2. Google Cloud Pratform で API とサービス > 認証情報 を開き、OAuth クライアントID を作成します。
    3. Google Cloud Pratform で 作成した クライアントID と クライアントシークレットを、Auth0 に設定します。
  5. Angular SPA を作ります。Auth0 と連携する処理として次のものを実装します:
    1. ログイン
    2. ログアウト
    3. 認証コールバック
    4. ユーザー情報の取得
  6. Angular SPA で、ユーザーのログイン状態を確認し、未ログインの場合に実装したログイン処理を実行します(AuthGuard)。
  7. Angular SPA で、ログイン後、ユーザー情報の取得処理を使ってメールアドレスを取得します。

なお作業内容については、 Auth0 の Angular Quick Start を参考にしています。Auth0はドキュメントが充実していて開発しやすいと感じます。こちらもご利用ください。

Auth0 Angular SDK Quickstarts: Login

※ Auth0へログインした状態で Quicke Start メニューをみると、 YOUR_DOMAIN などのプレースホルダーが実際の値に置き換わった状態でマニュアルが読めるのでログインしての利用をお勧めします。

Auth0 で テナント作成〜アプリケーション設定

テナント作成

Auth0 に登録し、右上部、ユーザーメニューから Create tenant を選んでテナントを作成してください。私は stg-cm-serverless として作成しました。

[与太話]テナントはどの単位で作成するか

テナントには複数のアプリケーションやAPI、登録されたユーザー情報を含みます。また、Logo URLSupport EmailCustom Domains がテナントにつきひとつ設定できます。このあたりを考慮してテナントを作成することになりそうです。個人的には、テナントという名前のとおり、ドメイン単位、会社単位で作ると都合がよいと考えています。さらに、エンドユーザーの課金情報などもテナント単位で管理されるため、環境ごとにわけたほうが無難です。仮にクラスメソッドが Auth0 を使ってサービス群を展開するとしたら次のようなテナント構成例が考えられます:

  • dev-classmethod: Developer プラン
  • stg-classmethod: Developer プラン
  • prd-classmethod: Developer Pro プラン

ちなみに Enterprise プランの場合は事情が異なり、デフォルトのルートドメインを決めます。 claclassmethod.auth0.com のような形式になります。テナントはルートメインにぶらさがる形となります。

  • Enterprise プラン (claclassmethod.auth0.com
    • dev.claclassmethod.auth0.com
    • stg.claclassmethod.auth0.com
    • prd.claclassmethod.auth0.com

プランは状況によって調整することになるでしょう。

アプリケーション作成

テナントを作成したら、 Applications > +CREATE APPLICAITION で Application を作成します。このときアプリケーションのタイプをきかれます。Single Page Web Applications としてください。アプリケーション名ですが、私はちょうど Slack のリアクション日時から勤務時間を計算して表示するアプリを作っているところだったので、これに認証機能を付け加えることにし、Attendance Managerとしました。何でもよいです。

images/auth0_create_app.png

アプリケーションを作り終えると、Settings でいくつかの設定項目が編集可能になっています。Googleログインという目的を達成するために必要な項目は次の3つです。

  • Allowed Callback URLs: 認可コード発行フェーズで Auth0 からコールバックされるURLです。
  • Allowed Web Origins: Silent Authentication という、SPAがトークンをリフレッシュする処理で必要になるアプリケーションのオリジン設定です。
  • Allowed Logout URLs: Auth0でログアウト処理を行った後にリダイレクトすることを許すURLです。

images/auth0-app-settings.png

…それぞれの値が設定されていないとき、どのような挙動になるのか気になりますね。

Allowed Callback URLs が設定されていない場合

ログインできません。 ログイン画面を開こうとする(Auth0のユニバーサルログイン処理を呼び出す)と、Auth0のエラー画面に遷移します。

images/auth0-callback-error.png

Allowed Web Origins が設定されていない場合

Quick Start のページに次のような記述があります:

You need to whitelist the URL for your app in the Allowed Web Origins field in your Application Settings. If you don't whitelist your application URL, the application will be unable to automatically refresh the authentication tokens and your users will be logged out the next time they visit the application, or refresh the page. - Auth0 Angular SDK Quickstarts: Login

アプリケーションのドメインを設定してね。さもないと、トークンの自動リフレッシュ処理ができないよ、と言っています。Auth0 で Allowed Web Origins の設定を空にして保存します。この状態で自動ログイン処理を走らせます。

images/auth0-invalid-web-origin.png

画面が真っ白です。Chrome の Developer Console で確認するとどうやら Silent Authentication のための https://xxx.auth0.com/authorize へのリクエストで 400 エラーとなっているようでした。

※ Silent Authentication は Single Page Application(セキュリティ上の理由でリフレッシュトークンをクライアント側に持てないタイプのアプリケーション)でトークンを更新する手段として利用します。

Allowed Logout URLs が設定されていない場合

ログアウト時にエラー画面が表示されます。 ログイン画面に遷移すると自動でログインされてしまうことから、正しくログアウト処理が走っていないとわかります。

images/auth0-failed-logout.png

Angular SPA のための設定を終えたところで、次は Google と連携します。

Google OAuth の設定、Auth0 Connections の設定

Auth0 と Google を連携するためには Google API の Client ID と Client Secret が必要です。これは Google Cloud Platform(GCP)で作成します。次の手順を参考にしました。

GCP - プロジェクトの作成

GCP > プロジェクトの選択 新しいプロジェクト で作成してください。

images/gcp-create-project.png

GCP - OAuth 認証画面を設定

作成したプロジェクト > 画面左上ナビゲーションメニュー > APIとサービス > OAuth同意画面 を開きます。次の項目を設定します:

  • アプリケーション名: 任意です。今回は Attendance Manager としました。
  • 承認済みドメイン: auth0.com を設定します。

保存してください。

GCP - 認証情報の作成

OAuth 認証を設定すると、左部メニューに 認証情報 があるのでそれを開き、認証情報を作成 > OAuth クライアント ID とします。

images/google-create-credentials.png

設定項目を入力します:

  • Name: 任意の名前を入力してください
  • Authorized JavaScript origins: https://YOUR_DOMAIN を設定します。私の場合は https://stg-cm-serverless.auth0.com です。
  • Authorized redirect URIs: https://YOUR_DOMAIN/login/callback を設定します。私の場合は https://stg-cm-serverless.auth0.com/login/callback です。

YOUR_DOMAIN は Auth0 の Applications > 作成した任意のアプリケーション > Settings > Domain で確認できます。

ドメイン情報を保存するとクライアントIDとシークレットが作成されます。これを Auth0 の Connections で設定します。

Auth0 - Connections の設定

Auth0 > 左部メニューのConnections > Social > Google を選びます。Client ID と Client Secret を入力する欄があるので、さきほど GCP で手に入れたIDとシークレットを入力・保存します。

次に Auth0 > 左部メニュー Applications > 作成した任意のアプリケーション > Connections と開きます。ここではアプリケーションで利用するログインタイプの有効/無効が設定できます。今回はGoogleログインのみにして、ユーザー名とパスワードを利用したログインは外しましょう。

images/gcp-auth0-connections.png

Auth0 - 新しいユニバーサルログインを利用

Auth0 > 左部メニューの Universal Login > Settings で、新しいほうを選択してください。

images/gcp-auth0-universal-login.png

違いは、クラシックログインが JavaScript ウィジェットを使用するのに対して、新しいほうはサーバーサイドでレンダリングされる点だそうです。

これで Google ログインを利用する準備ができました。あとは Angular SPA に組み込んでいきます。

Angular SPA に組み込む

SPAでDynamoDBに保存された勤務時間を一覧する画面を作ったので、それに認証機能を組み込んでみることにしました。

images/angular_attendance_management.png

新しくアプリケーションを作成する場合は、Angular CLI をインストールしてアプリケーションを作成してください。

npm install -g @angular/cli
ng new auth0-angular-demo # 任意のアプリケーション名

認証機能が組み込まれていない状態からSDKを入れて実装していきましょう。

SDK をインストールする

npm install @auth0/auth0-spa-js --save

Angular の認証サービスを作成する

ng g service domains/auth/auth-use-case

コードの中身については Angular: Login #Add an Authentication Service で記載されているものとほぼ同じです。

src/app/domains/auth/auth-use-case.service.ts

import { Injectable } from '@angular/core';
import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client';
import { from, of, Observable, BehaviorSubject, combineLatest, throwError } from 'rxjs';
import { tap, catchError, concatMap, shareReplay } from 'rxjs/operators';
import createAuth0Client from '@auth0/auth0-spa-js';
import { environment } from '../../../environments/environment';
import { Router } from '@angular/router';

@Injectable({
  providedIn: 'root'
})
export class AuthUseCaseService {

  // Create an observable of Auth0 instance of client
  auth0Client$ = (from(
    createAuth0Client({
      // (1) Auth0 クライアントの作成
      domain: environment.auth0.domain,
      client_id: environment.auth0.clientId,
      redirect_uri: `${window.location.origin}/callback`
    })
  ) as Observable<Auth0Client>).pipe(
    shareReplay(1), // Every subscription receives the same shared value
    catchError(err => throwError(err))
  );

  // Define observables for SDK methods that return promises by default
  // For each Auth0 SDK method, first ensure the client instance is ready
  // concatMap: Using the client instance, call SDK method; SDK returns a promise
  // from: Convert that resulting promise into an observable
  isAuthenticated$ = this.auth0Client$.pipe(
    concatMap((client: Auth0Client) => from(client.isAuthenticated())),
    tap(res => this.loggedIn = res)
  );
  handleRedirectCallback$ = this.auth0Client$.pipe(
    concatMap((client: Auth0Client) => from(client.handleRedirectCallback()))
  );
  // Create subject and public observable of user profile data
  private userProfileSubject$ = new BehaviorSubject<any>(null);
  userProfile$ = this.userProfileSubject$.asObservable();
  // Create a local property for login status
  loggedIn: boolean = null;

  constructor(private router: Router) {
  }

  // When calling, options can be passed if desired
  // https://auth0.github.io/auth0-spa-js/classes/auth0client.html#getuser
  getUser$(options?): Observable<any> {
    return this.auth0Client$.pipe(
      concatMap((client: Auth0Client) => from(client.getUser(options))),
      tap(user => this.userProfileSubject$.next(user)),
    );
  }

  localAuthSetup() {
    // This should only be called on app initialization
    // Set up local authentication streams
    const checkAuth$ = this.isAuthenticated$.pipe(
      concatMap((loggedIn: boolean) => {
        if (loggedIn) {
          // If authenticated, get user and set in app
          // NOTE: you could pass options here if needed
          return this.getUser$();
        }
        // If not authenticated, return stream that emits 'false'
        return of(loggedIn);
      })
    );
    checkAuth$.subscribe((response: { [key: string]: any } | boolean) => {
      // If authenticated, response will be user object
      // If not authenticated, response will be 'false'
      this.loggedIn = !!response;
    });
  }

  login(redirectPath: string = '/') {
    // A desired redirect path can be passed to login method
    // (e.g., from a route guard)
    // Ensure Auth0 client instance exists
    this.auth0Client$.subscribe((client: Auth0Client) => {
      // Call method to log in
      console.log(client);
      client.loginWithRedirect({
        // (2) リダイレクトURLを設定
        redirect_uri: `${window.location.origin}/callback`,
        appState: {target: redirectPath}
      });
    });
  }

  handleAuthCallback() {
    // Only the callback component should call this method
    // Call when app reloads after user logs in with Auth0
    let targetRoute: string; // Path to redirect to after login processsed
    const authComplete$ = this.handleRedirectCallback$.pipe(
      // Have client, now call method to handle auth callback redirect
      tap(cbRes => {
        // Get and set target redirect route from callback results
        targetRoute = cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/';
      }),
      concatMap(() => {
        // Redirect callback complete; get user and login status
        return combineLatest(
          this.getUser$(),
          this.isAuthenticated$
        );
      })
    );
    // Subscribe to authentication completion observable
    // Response will be an array of user and login status
    authComplete$.subscribe(([user, loggedIn]) => {
      // Redirect to target route after callback processing
      this.router.navigate([targetRoute]);
    });
  }

  logout() {
    // Ensure Auth0 client instance exists
    this.auth0Client$.subscribe((client: Auth0Client) => {
      // Call method to log out
      client.logout({
        // (3) ログアウトのための情報
        client_id: environment.auth0.clientId,
        returnTo: `${window.location.origin}`
      });
    });
  }

}

コードのうちアプリケーション固有の設定を反映しなければならないのが(1)、(2)、(3)です。Auth0 のドキュメントではクライアントIDなどを直接記載していましたが、私は src/environments/environment.ts ファイルへ切り出すことにしました。

export const environment = {
  production: false,

  attendanceApi: {
    baseUrl: 'https://xxxxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1'
  },

  auth0: {
    domain: 'stg-cm-serverless.auth0.com',
    clientId: '806KLJ*****************JLLL',
  }
};

あとはこのサービスを画面から呼び出していきます。作業内容は次の5つです:

  • ベースとなるAppComponentにログイン状態を反映する処理を追加します
  • ナビゲーションバーからログアウトできるようにします
  • ログイン処理の過程で遷移する callback コンポーネントを作成します
  • Angular の AuthGuard を作って、デフォルトページに認証をかけます
  • 取得したユーザープロファイルを画面に表示します

それぞれみていきましょう。先述のように、手順は Auth0 Angular SDK Quickstarts: Login に沿っています。ただし、Angular Material を利用している点や既存のアプリケーションに組み込む点からそっくりそのままではない点に注意してください。

ログイン状態を復元する

ユーザーがログイン状態かそうでないかは、保持しているトークンが有効かで判定できます。都度 Auth0 側に確認することも可能ですが、ログイン状態はコンポーネントの表示・非表示に使ったり、リダイレクトしたりと何かと参照する機会が多いです。そこでサンプルの実装では、AuthUseCaseServiceloggedIn: boolean 変数に状態をもたせてこれを各コンポーネントから読み込めるようにしています。AuthUseCaseServiceはシングルトンインスタンスとして振る舞うのでコンポーネント間で共有できるというわけです。

ローカルで保持している変数ですので、初回アクセス時/リロード時に更新しなければなりません。そこで、必ず通るルートの AppComponent.ngOnInit()loggedIn: boolean へリセットする処理を追加します。

src/app/app.component.ts

import { Component, OnInit } from '@angular/core';
import { AuthUseCaseService } from './domains/auth/auth-use-case.service';

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

  constructor(private auth: AuthUseCaseService) {
  }

  ngOnInit(): void {
    this.auth.localAuthSetup();
  }
}

ナビゲーションバーからログアウトできるようにする

私が組み込もうとしていたアプリケーションには、Angular Materialを利用して作成したコンポーネントがありました。Angular Material コンポーネントとしてgenerateすると自動でナビゲーションエリアを用意してくれるので、ここにログアウトを実装していきます。新しくアプリケーションを作成した場合、つぎのようにして Angular Material と周辺ツールをインストールしてください。

ng add @angular/material
npm install --save @angular/flex-layout

その後、ナビゲーションバー付きマテリアルコンポーネントを追加します。

ng generate @angular/material:material-nav --name components/user-attendance

次のようなHTMLができあがるはずです。

src/app/components/user-attendance/user-attendance.component.html

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav #drawer class="sidenav" fixedInViewport
      [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
      [mode]="(isHandset$ | async) ? 'over' : 'side'"
      [opened]="(isHandset$ | async) === false">
    <mat-toolbar>Menu</mat-toolbar>
    <mat-nav-list>
      <a mat-list-item href="#">Link 1</a>
      <a mat-list-item href="#">Link 2</a>
      <a mat-list-item href="#">Link 3</a>
    </mat-nav-list>
  </mat-sidenav>
  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <button
        type="button"
        aria-label="Toggle sidenav"
        mat-icon-button
        (click)="drawer.toggle()"
        *ngIf="isHandset$ | async">
        <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
      </button>
      <span>attendance-management-web</span>
    </mat-toolbar>
    <!-- Add Content Here -->
  </mat-sidenav-content>
</mat-sidenav-container>

いまは文字列だけになっているナビゲーションエリアを修正します。

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav #drawer class="sidenav" fixedInViewport
               [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
               [mode]="(isHandset$ | async) ? 'over' : 'side'"
               [opened]="(isHandset$ | async) === false">
    <mat-toolbar>Menu</mat-toolbar>
    <mat-nav-list>
      <a mat-list-item href="#">ユーザー別勤怠</a>
    </mat-nav-list>
  </mat-sidenav>
  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <div fxFill fxLayout="row" fxLayoutAlign="start center">
        <div fxFlex="50%">
          <button
            type="button"
            aria-label="Toggle sidenav"
            mat-icon-button
            (click)="drawer.toggle()"
            *ngIf="isHandset$ | async">
            <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
          </button>
          <span>ユーザー別勤怠</span>
        </div>
        <div fxFlex="50%">
          <div fxLayout="row" fxLayoutAlign="end center">
            <app-navbar *ngIf="auth.userProfile$ | async as profile" [displayUserName]="profile.name"></app-navbar>
          </div>
        </div>
      </div>

    </mat-toolbar>

flex-layout で領域を分けて <app-navbar></app-navbar>という別コンポーネントを追加しています。ここからすでにSPAを持っっている方も、新しく作成した方も共通です。

ng generate component components/navbar

src/app/components/navbar/navbar.component.ts

import { Component, Input, OnInit } from '@angular/core';
import { AuthUseCaseService } from '../../domains/auth/auth-use-case.service';

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

  // ユーザー名をナビゲーションバーに表示したい。親コンポーネントから受け取る。
  @Input()
  displayUserName: string;

  // HTMLで使う 認証サービスをインジェクション
  constructor(public auth: AuthUseCaseService) {
  }

  ngOnInit() {
  }

}

src/app/components/navbar/navbar.component.html

<button mat-button [matMenuTriggerFor]="menu">
  <span class="mat-body-2">{{displayUserName}}様</span>
  <mat-icon>expand_more</mat-icon>
  <mat-icon class="user-icon">supervised_user_circle</mat-icon>
</button>
<mat-menu #menu="matMenu">
  <button *ngIf="!auth.loggedIn" mat-menu-item (click)="auth.login()">
    <mat-icon>supervisor_account</mat-icon>
    <span>ログイン</span>
  </button>
  <button *ngIf="auth.loggedIn" mat-menu-item (click)="auth.logout()">
    <mat-icon>exit_to_app</mat-icon>
    <span>ログアウト</span>
  </button>
</mat-menu>

マテリアルコンポーネントを利用するために、上記モジュール一式を src/app/app.module.ts に追加します。

...
  imports: [
    ...
    FlexLayoutModule,
    MatMenuModule
  ],
...

これでログアウト機能つきナビゲーションバーが実装できました。

認証コールバックを用意する

認証コードフロー もしくは インプリシットフロー で、Auth0 からのコールバックを処理するコンポーネントが必要です。必要な処理は認証サービスが請け負ってくれるので、コンポーネントとしては認証サービスのしかるべき処理を呼び出せばOKです。

ng g component components/auth/callback
import { Component, OnInit } from '@angular/core';
import { AuthUseCaseService } from '../../../domains/auth/auth-use-case.service';

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

  constructor(private auth: AuthUseCaseService) { }

  ngOnInit() {
    this.auth.handleAuthCallback();
  }

}

/callback パスでこのコンポーネントが呼び出されるよう、src/app/app-routing.module.ts を修正しましょう。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { UserAttendanceComponent } from './components/user-attendance/user-attendance.component';
import { CallbackComponent } from './components/auth/callback/callback.component';
import { AuthGuard } from './domains/guard/auth.guard';


const routes: Routes = [
  {
    path: '',
    redirectTo: 'user-attendance',
    pathMatch: 'full'
  },
  {
    path: 'user-attendance',
    component: UserAttendanceComponent
  },
  {
    path: 'callback',
    component: CallbackComponent
  }
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {
}

これで認証の準備が整いました。

AuthGuard を用意する

さて、あとはログイン画面を呼び出すだけです。どこで呼び出すか、という話で、もちろんAngular側でログインコンポーネントを用意しそこで呼び出すのも手です。が、今回はせっかく Auth0 が Universal Login で画面を用意してくれているので、こちらであらためて用意することは避けましょう。Angular には画面を表示する際に任意の処理を挟んで 表示してよいかを判定 させることができます。CanActivate というインタフェースを実装し、ルーティング設定させることで実現可能です。

ng g guard domains/guard/auth
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthUseCaseService } from '../auth/auth-use-case.service';
import { tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  constructor(private auth: AuthUseCaseService) {
  }

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    return this.auth.isAuthenticated$.pipe(
      tap(loggedIn => {
        if (!loggedIn) {
          this.auth.login(state.url);
        }
      })
    );
  }
}

canActivate()の実装に注目してください。Auth0 にログイン状態を確認しに行き(this.auth.isAuthenticated$.pipe(tap(loggedIn)))、ログイン状態でなければ ログイン処理を呼び出します。 ここでやっとログインが登場しましたね。あとはこれを、先ほど作成した /user-attendance に組み込むようルーティングを修正します。

src/app/app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { UserAttendanceComponent } from './components/user-attendance/user-attendance.component';
import { CallbackComponent } from './components/auth/callback/callback.component';
import { AuthGuard } from './domains/guard/auth.guard';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'user-attendance',
    pathMatch: 'full'
  },
  {
    path: 'user-attendance',
    component: UserAttendanceComponent,
    canActivate: [AuthGuard]
  },
  {
    path: 'callback',
    component: CallbackComponent
  }
];

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

組込み完了です。

ユーザープロファイルを画面に表示する

お目当てのメールアドレスを含むユーザープロファイルを画面に表示させましょう。認証サービスで userProfile$ という Observable がすでに定義されているので、これが使えそうです。/user-attendance コンポーネントに表示するようHTMLを修正します。

src/app/components/user-attendance/user-attendance.component.html

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav #drawer class="sidenav" fixedInViewport
               [attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
               [mode]="(isHandset$ | async) ? 'over' : 'side'"
               [opened]="(isHandset$ | async) === false">
    <mat-toolbar>Menu</mat-toolbar>
    <mat-nav-list>
      <a mat-list-item href="#">ユーザー別勤怠</a>
    </mat-nav-list>
  </mat-sidenav>
  <mat-sidenav-content>
    <mat-toolbar color="primary">
      <div fxFill fxLayout="row" fxLayoutAlign="start center">
        <div fxFlex="50%">
          <button
            type="button"
            aria-label="Toggle sidenav"
            mat-icon-button
            (click)="drawer.toggle()"
            *ngIf="isHandset$ | async">
            <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
          </button>
          <span>ユーザー別勤怠</span>
        </div>
        <div fxFlex="50%">
          <div fxLayout="row" fxLayoutAlign="end center">
            <app-navbar *ngIf="auth.userProfile$ | async as profile" [displayUserName]="profile.name"></app-navbar>
          </div>
        </div>
      </div>
    </mat-toolbar>
    <!-- Add Content Here -->
    <pre *ngIf="auth.userProfile$ | async as profile">
      <code>{{ profile | json }}</code>
    </pre>
  </mat-sidenav-content>
</mat-sidenav-container>

  • 取得したプロファイルのうち name<app-navbar></app-navbar> へ渡します
  • どんな値がとれたかjsonで表示させます

これで実装は完了です。

動作確認

Google ログインができて、ユーザープロファイルが取得できるか確認しましょう。Angularのテスト用サーバーを起動してください。

ng serve

http://localhost:4200 へアクセスします。初回アクセスですので、ログイン画面が表示されるはずですね。

dostuff-login.png

設定どおり、ユーザー名/パスワードによるログインはなく、 Google ログインのみ表示された状態です。任意の Google アカウントを入力してログインします。

images

無事、/user-attendance が表示されましたね。どうやらプロファイルもとれてそうです。右上部に注目すると、ナビゲーションで設定した <span>{{displayUserName}}様</span> で正しく名前に置き換わっていることがわかります。また、プロファイルのJSONも表示されています。どうやらメールアドレスの他にも、アバター画像のURLや名前が取得できているようです。最後にログアウトしてみましょう。

dostuff-login.png

認証情報がクリアされ、http://localhost:4200 へリダイレクトされています。さらにその後 Angular 内で /user-attendance にルーティング => AuthGuard でひっかかってログイン画面表示、と、こちらも想定どおりの動きになりました。Angular SPA への組込み完了です。

[備考]エラーに遭遇したら

途中、認証がうまくいかないなどエラーに遭遇することもあります。Auth0 > 左部メニューLogs を活用してください。各イベントに対して成功/失敗ログが記録されており、失敗した場合は考えられる原因まで書いてくれています。たいていの場合ここを見れば解決します。ありがたいですね。コールバックURLをわざと空にしたときのエラーログを見てみましょう。

dostuff-logs.png

ログから、コールバックURLが正しく登録されていないことがわかります。

まとめ

Angular SPA に Google ログインを組み込んでメールアドレスを取得しました。認証が通ってプロファイル情報が取得できると、アプリケーションとしてユーザー固有のアクションが取りやすくなり、さらに機能の幅が広がります。SPAは実装と公開がお手軽にできる分、アクセス制御が難しくなかなか公開に踏み切れないケースも多いのではないでしょうか。Auth0 をうまく使うことで、安く手軽に、すばやく認証を組み込めます。Auth0には他にもまだまだたくさんの機能があるので、引き続き試していきたいです。個人的に気になっているのは、

  • SPA + API サーバーというセットではどのような認証構成にするべきか
  • シングルサインオンの挙動を試してみたい

という点です。Auth0 ですが、ドキュメントが本当によいです。具体的には、

これらがデベロッパーフレンドリーです。総じて、「必要な材料は全部提供するぜ」という気概が見えます。ありがたいですね。この記事も、Auth0 導入を検討している方の参考になれば幸いです。

クラスメソッドでは Auth0 のハンズオンセミナーを開催しています

興味があればぜひご参加ください。