SharePointで作られた社内ポータルをAngular+GraphAPIでステキに閲覧する①

会社のポータルサイトがOffice365のSharePointで構築されているのですが、どうにも見づらいのでAngular8 + Graph APIで独自に構築してみることにしました。
GraphAPIを使うまでの道のりを残しておこうと思います。

長くなりそうなので、今回は何回かに分けて書いていきます。
続き⇒[SharePointで作られた社内ポータルをAngular+GraphAPIでステキに閲覧する②](https://qiita.com/teracy55/items/8cd76a52c8adaaf4050a)

なお、APIを使って色々やる場合、以下のようなフローとなるそうなので、今回は①、②の部分を書きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/4f03a886-4d13-43de-bb1b-fd4a36894954.png)
※この図はAzureでアプリ登録後のクイックスタートで確認できます


# Azureでアプリを登録する

## アプリ登録
- 「すべてのサービス⇒ID⇒アプリの登録」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/c648d0b5-14f2-b1fc-4e3f-c30dd124b9c7.png)

- 「新規登録」押下
- アプリ名を入力し、登録

※概要で`クライアントID`、`テナントID`が確認できます。
　Angularへの組み込みが必要になりますので、メモっておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/5a87b002-8670-8f7d-dd34-4a5d5316f4d0.png)




## 各種設定

### リダイレクトURLを設定

- 「認証」からリダイレクトURLを設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/099bd338-b3d5-2c1a-af31-7cceb800cc17.png)
※ローカル環境で動かす場合は「localhost:4200」を登録
　⇒localhost以外はhttps必須みたいです。
　また、複数件登録できるので、localhost、共用開発環境、本番環境といった登録ができます。
　⇒Angular側で指定するリダイレクトURLで切り分けられると思います。

### 暗黙的な許可フローの許可
ログインやアクセストークンの取得を暗黙的に実行したい場合はチェックを入れて保存します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/f2e31bdd-649e-e38f-e51d-ac5ab10de283.png)



# Angularでフロントエンドの構築

Azureにクイックスタートがありますが、Angular向けに「[@azure/msal-angular](https://www.npmjs.com/package/@azure/msal-angular)」というパッケージが用意されているので、以降はそちらの導入手順に従って実装していきます。


Angularバージョンは以下で進めていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/45d5668d-3a8c-8a0c-bb12-c83a26302408.png)


## テンプレートプロジェクトの生成
以下のコマンドでテンプレートを生成します

```
ng new sp-portal --routing
```
※ルーティング欲しいので`--routing`オプション付けてます


## msal-angularのインストール

クイックスタートはExpress.jsによるJavascriptコードで面倒くさいので、[msal-angular](https://www.npmjs.com/package/@azure/msal-angular)をインストールします

```
npm install @azure/msal-angular --save
```

### 必要なパッケージをインストール

msal-angularは`rxjs-compat`を使うみたいなので、インストールします。

```
npm install --save rxjs-compat
```



## コードの組み込み

ここからは[@azure/msal-angularのページ](https://www.npmjs.com/package/@azure/msal-angular)を参考に組み込んでいきます

### AppModuleでMsalModuleをインポート
ここで、Azureに登録したアプリのクライアントIDとテナントID、リダイレクトURLを指定します。
※リダイレクトURLはログインやログアウト完了後にリダイレクトするURL
　Azure側で設定したURLと一致させる必要があります。

```app.module.ts
import { MsalModule } from '@azure/msal-angular';
import { environment } from "../environments/environment";
@NgModule({
  ・・・
  imports: [
    ・・・
    MsalModule.forRoot({
      clientID: environment.msal.clientID,
      authority: environment.msal.authority,
      redirectUri: environment.msal.redirectUrl,
    })
  ]
})
export class AppModule { }
```
※設定できる項目は[こちら](https://www.npmjs.com/package/@azure/msal-angular#config-options-for-msal-initialization)を参照

なお、今回は各種設定値を`environments.ts`に切り出しています。

```environment/environment.ts
export const environment = {
  production: false,
  msal: {
    clientID: "{Your Client ID}",
    authority: "https://login.microsoftonline.com/{Your Tenant ID}",
    redirectUrl: "http://localhost:4200/",
  }
};

```

### ルーティング設定

```app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { MsalGuard } from '@azure/msal-angular';
import { LoginComponent } from './login/login.component';

const routes: Routes = [
  { path: 'login', component: LoginComponent },
  // Angular8のDynamic Imports
  { path: 'pages', canActivate: [MsalGuard], loadChildren: () => import('./pages/pages.module').then(m => m.PagesModule) },
  { path: '', redirectTo: 'login', pathMatch: 'full' }
];

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

`path: 'pages'`に`canActive:'MsalGuard'`を指定しているため、/pages～のURLにアクセスする際にMsalGuardによるチェックが走り、認証できていない場合は強制的にOffice365のログイン画面にリダイレクトされます。


ちなみに、今回は↓のようなディレクトリ構成にしてます。

```
└─src
    │  
    ├─app
    │  │  app-routing.module.ts
    │  │  app.component.html
    │  │  app.component.ts
    │  │  app.module.ts
    │  │  
    │  ├─login
    │  │      login.component.ts
    │  │      
    │  └─pages
    │      │  pages-routing.module.ts
    │      │  pages.module.ts
    │      │  
    │      ├─detail
    │      │      detail.component.ts
    │      │      
    │      └─top
    │              top.component.ts
    │              
    ├─environments
    │      environment.prod.ts
    │      environment.ts
    │      
    └─services
            auth.service.ts
```

#### 画面遷移イメージ
今回は、ヘッダにログイン／ログアウトボタンを置いて、任意のタイミングで認証できるようにしたいと思います。
※[Angular Material](https://material.angular.io/)と[flex-layout](https://github.com/angular/flex-layout)を使って画面構築してます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/829c84d2-2131-a31d-3c03-c8f2bae502b3.png)
　↓　ログイン押下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/6f6405dc-833f-e59b-9eb0-c5c2575c8d79.png)
※2段階認証にも対応してます
　↓　ログイン後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/23a001aa-52b1-122d-e274-76ce9fc910bf.png)
※ログイン後はログインボタンはログイン中ユーザーのアドレスを表示し、クリックするとメニューが開いてログアウトができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/3c77dd20-934e-348a-7279-7b5866048ddd.png)


### 各種コンポーネント

#### app.component

ここでは、主にヘッダ部分の制御を記載しています。

```src/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { User } from 'msal';
import { AuthService } from 'src/services/auth.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  /** ログイン中ユーザー情報 */
  user: User = null;

  constructor(
    private router: Router,
    private authService: AuthService,
  ) { }

  ngOnInit() {
    // ユーザー取得
    this.user = this.authService.getUser();
  }

  /**
   * ログインチェック
   */
  get isLogin() {
    return this.user ? true: false;
  }

  /**
   * タイトル押下
   */
  OnClickTitle() {
    if (this.isLogin) {
      this.router.navigate(['/pages']);
    }
  }

  /**
   * ログイン押下
   */
  async OnClickLogin() {
    // ログインポップアップ表示
    const token = await this.authService.showLoginPopup();
    if (token) {
      // ユーザー情報を取得
      this.user = this.authService.getUser();
      // TOP画面に遷移
      this.router.navigate(['/pages']);
    } else {
      this.user = null;
      alert('ログイン失敗');
    }
  }

  /**
   * ログアウト押下
   */
  async OnClickLogout() {
    this.user = null;
    // ログアウト
    this.authService.logout();
  }

}
```

```app.component.html
<mat-toolbar fxLayout="row" fxLayoutAlign="space-between" color="primary">
    <div fxFlexAlign="center">
        <button mat-flat-button color="primary" (click)="OnClickTitle()"><h1>Title</h1></button>
    </div>
    <div fxFlexAlign="center"></div>
    <div fxFlexAlign="center">
        <button mat-raised-button color="accent" *ngIf="!isLogin" (click)="OnClickLogin()">ログイン</button>
        <button [matMenuTriggerFor]="menu" *ngIf="isLogin" mat-raised-button color="primary">{{user.displayableId}}</button>
        <mat-menu #menu="matMenu">
            <button mat-menu-item (click)="OnClickLogout()">
                <mat-icon>exit_to_app</mat-icon>
                <span>ログアウト</span>
            </button>
        </mat-menu>
    </div>
</mat-toolbar>
<router-outlet></router-outlet>
```

#### login.component.ts

ログイン済みかチェックし、ログイン済みであれば/pagesに遷移させます。

```src/app/login.compoonent.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from 'src/services/auth.service';

@Component({
  selector: 'app-login',
  template: '<span>ログインしてください</span>',
})
export class LoginComponent implements OnInit {
  constructor(
    private router: Router,
    private authService: AuthService,
  ) { }

  ngOnInit() {
    if (this.authService.getUser()) {
      // ログイン済みの場合はTOPページに遷移
      this.router.navigate(['/pages']);
    }
  }
}
```

#### src/app/pages下のcomponent

今回はログインがメインなので、暫定の画面です

```src/app/pages/top/top.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-pages-top',
  template: `
  <h1>Top Component</h1>
  <ul>
    <li><a routerLink="/pages/detail/1">ID1のページを表示</a></li>
    <li><a routerLink="/pages/detail/2">ID2のページを表示</a></li>
  </ul>
  `,
})
export class TopComponent {
}
```
```src/app/pages/detail/detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';

@Component({
  selector: 'app-pages-detail',
  template: `
  <h1>Detail Component</h1>
  <span>ID:{{id}}のページです</span>
  `,
})
export class DetailComponent implements OnInit {
  id: number;

  constructor(private ar: ActivatedRoute) {}

  ngOnInit() {
    // IDを取得
    this.ar.params.subscribe((params: Params) => {
      if (params['id']) {
        this.id = params['id'];
      } else {
        alert('idを指定してください')
      }
    })
  }
}
```


### サービスの登録

#### サービスの作成

ログイン／ログアウト、ユーザー情報取得処理を定義しています。
なお、[サンプル](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular/samples/MSALAngularDemoApp)でも[ユーザー情報の有無でログインしているかチェック](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/samples/MSALAngularDemoApp/src/app/app.component.ts#L23)していました。

```src/services/auth.service.ts
import { Injectable } from '@angular/core';
import { MsalService } from '@azure/msal-angular';

@Injectable({
    providedIn: 'root',
})
export class AuthService {
    constructor(private　msalService: MsalService) { }

    /**
     * ユーザー取得
     */
    getUser() {
        return this.msalService.getUser();
    }

    /**
     * ログインポップアップ表示
     */
    async showLoginPopup() {
        try {
            return await this.msalService.loginPopup();
        } catch (err) {
            console.log(err);
            return null;
        }
    }

    /**
     * ログアウト
     */
    logout() {
        this.msalService.logout();
    }
}
```

SharePointAPI用のサービスを作成
※今回はまだ空です。

```src/services/sharepoint-api.service.ts
import { Injectable } from '@angular/core';

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

}
```

#### プロバイダーにサービスを登録

`TTP_INTERCEPTORS`で`MsalInterceptor`を指定することで指定のサービスを呼び出す際に認証チェックが走り、認証できていない場合はOffice365のログイン画面にリダイレクトするようになります。

```src/app/app.module.ts
import { MsalModule, MsalInterceptor } from '@azure/msal-angular';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { SharePointApiService } from 'src/services/sharepoint-api.service';
@NgModule({
  ・・・
  providers: [
    SharePointApiService,
    { provide: HTTP_INTERCEPTORS, useClass: MsalInterceptor, multi: true }
  ],
})
export class AppModule { }
```

※`AuthService`は認証チェック不要な処理しか置かないので、providersには登録してません。
　登録してしまうとgetUser読んだだけでログイン画面に勝手に飛んでしまうので。。。



# サーバー起動

これで準備は完了です！
以下のコマンドで起動します。

```
ng serve
```

ブラウザで`http://localhost:4200`にアクセスします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/82e2baa2-3dd9-21c2-c238-277e76f9c91c.png)




# まとめ
無事にログインまではできました。
次のアクセストークン取得は別途記事に起こしたいと思います。









# TIPS

ログイン周りでいくつかはまったところがあったので、書いておきます。
※上記手順には反映済みです。

## AADSTS700054: response_type 'id_token' is not enabled for the application.

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/a1af6f71-b035-08ee-ab3b-7210404288c6.png)

Azureの「アプリの登録⇒認証」から`IDトークン`にチェックを入れる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/b7afbded-21a4-ecbc-08d8-011a4afc8a41.png)

※一度キャッシュを消すか、シークレットウィンドウで開く

## AADSTS500113: No reply address is registered for the application.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/227a88d1-1362-433f-888d-35807a96aa70.png)

Azureの「アプリの登録⇒認証」でリダイレクトURLを選択する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/e1d7b824-5c33-9dc4-fb97-4a2245c58d51.png)

※ここで指定したURLとAppModuleで指定した`redirectUri`が一致しないとエラーになる

## AADSTS700051: response_type 'token' is not enabled for the application.

acquireTokenSilentでアクセストークンを取得しようとした際に発生
マニフェストで暗黙的なトークン取得を許可する必要があるらしい
参考：https://community.dynamics.com/crm/b/akmscrmblog/archive/2016/03/16/response-type-token-is-not-enabled-for-the-application

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/1b9dcb16-48d0-e158-6b0a-3509eac83cde.png)
`oauth2AllowImplicitFlow`をtrueに変更する