「雰囲気で使わずきちんと理解する！整理してOAuth2.0を使うためのチュートリアルガイド」のチュートリアルをAngularで実装してみる

**注意：Public ClientであるSPAで`認可コードグラント`を実装していますが、実際には`認可コードグラント + PKCE`が推奨となります。**
**2020/5/15 stateの検証を追加しました。**

---

[「雰囲気で使わずきちんと理解する！整理してOAuth2.0を使うためのチュートリアルガイド」](https://www.amazon.co.jp/gp/product/B07XT8H2YG/ref=ppx_yo_dt_b_d_asin_title_o04?ie=UTF8&psc=1) の「第6章 チュートリアル」の`認可コードグラント`での認可を`Angular`で実装してみます

OAuthの理解は以下記事もとても参考になります。
[「OAuth 2.0 全フローの図解と動画」](https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f)

本書チュートリアルではCLIでGoogleの`Photos Library API`でOAuthを利用しています
Google Photos APIの仕様はこちらです
https://developers.google.com/photos/library/reference/rest

## Not OAuth認証
今回は***「OAuth認証（認証プロトコル）」***でなく***「OAuth（認可プロトコル）」***としての利用です
「OAuth認証」と「OAuth」の認識が間違われることが多いみたいなのでご注意を

OAuth認証についてはこちらを参考にしてください
https://auth0.com/docs/quickstart/spa/angular2
https://dev.classmethod.jp/articles/auth0-google-login-angular-spa/

## 準備

### Angular CLIでプロジェクト作成

プロジェクト名`angular-oauth-sample`で作成

### APIのクライアントID、クライアントシークレットを取得
[Google Cloud Platform](https://cloud.google.com/apis/docs/getting-started?hl=ja)でAPIの設定をします
クライアントID、クライアントシークレットの取得方法の詳細は割愛しますが、以下を実施してください

- `Photos Library API`の有効化
- コールバックURIを`http://localhost:4200/callback`に設定

### Googleフォトに画像のアップロード

https://www.google.com/intl/ja/photos/about/
Googleフォトの画像を表示する実装のため、画像をアップロードします

最近お気に入りのtacoさんを利用します
![taco.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239707/453dfeb9-160f-e6c1-420b-4149b2bb40f6.png)


## 実装

ディレクトリ構造は以下です

```
src/
 ├── app/
 │     ├── component/
 │     │   ├── callback/           // 認可コードを受け取るコールバック先、トークンエンドポイントへのリクエスト
 │     │   ├── display-photo/      // リソースアクセスポイントへのリクエスト
 │     │   └── home/               // 認可エンドポイントへのリクエスト
 │     ├── service/
 │     │   └── token.service.ts    // アクセストークンの管理
 │     ├── app.component.html
 │     ├── app.component.ts
 │     ├── app.module.ts
 │     └── app-routing.module.ts
 └── environments/environment.ts   // OAuth情報の管理

```

処理の手順としては以下です

1. 認可エンドポイントにリクエストを送る（`home`）
2. （Googleの）認可画面にリダイレクトされ、Googleの認証情報を入力する
3. 認可コードがコールバックされる（`callback`）
4. 認可コードを利用してトークエンドポイントにリクエストを送り、アクセストークンを取得する（`callback`）
5. アクセストークンを利用してGoogle Photo APIで写真取得する

### OAuth情報

準備したクライアントID,クライアントシークレットを`environment.ts`に設定します

```typescript:environment.ts
export const environment = {
  auth: {
    authUri: 'https://accounts.google.com/o/oauth2/auth',
    tokenUri: 'https://oauth2.googleapis.com/token',
    clientId: 'xxxxxxxx-xxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
    clientSecret: 'xxxxxxxxxx',

    // 認可コードを受け取るコールバック先
    redirectUri: 'http://localhost:4200/callback',

    // Google Phots API画像取得のスコープ
    scope: 'https://www.googleapis.com/auth/photoslibrary.readonly',

    // Google Phots API画像取得のURI
    photosAlbumUri: 'https://photoslibrary.googleapis.com/v1/albums'
  }
};
```

### ルーティング設定

```typescript:app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './component/home/home.component';
import { CallbackComponent } from './component/callback/callback.component';
import { DisplayPhotoComponent } from './component/display-photo/display-photo.component';

const routes: Routes = [
  {path: 'home', component: HomeComponent},
  {path: 'callback', component: CallbackComponent},
  {path: 'photo', component: DisplayPhotoComponent},
  {path: '**', redirectTo: 'home', pathMatch: 'full'}
];

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

### 認可エンドポイントへのリクエスト
- 認可エンドポイントへのリクエスト + 認可画面へのリダイレクトに`window.location.href`を利用しました
    - 何か他にいい方法あればコメントよろしくお願いいたします
- CSRF対策としてstateを設定し、認可コードがコールバックした際に検証すえうためにsessionStorageに保持しています（@ritouさん コメントでのご指摘ありがとうございます）

```typescript:home.component.ts
import { Component } from '@angular/core';
import { environment } from '../../../environments/environment';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent {

  constructor() { }

  public connectApi() {

    // stateの発行
    const state = Math.random().toString(32).substring(2);
    sessionStorage.setItem('state', state);

    // リクエストパラメータ
    const params = new Map();
    params.set('response_type', 'code');
    params.set('client_id', environment.auth.clientId);
    params.set('state', state);
    params.set('scope', environment.auth.scope);
    params.set('redirect_uri', environment.auth.redirectUri);

    let authUrl = `${environment.auth.authUri}?`;
    params.forEach((value: string, key: string) => {
      authUrl += `${key}=${value}&`;
    });

    // 認可エンドポイントにリクエスト
    window.location.href = authUrl;
  }
}
```

### 認可コード + トークエンドポイントへのリクエスト

Googleの認可画面で情報入力しリクエストを送ると、***認可コード***が`http://localhost:4200/callback `に返ってきます
***認可コード***をもとに***トークエンドポイント***にリクエストを送り、***アクセストークン***を取得します

```typescript:callback.component.ts
/**
 * トークンエンドポイントのリクエストパラメータ
 */
interface TokenEndPointRequest {
  client_id: string;
  client_secret: string;
  grant_type: string;
  code: string;
  redirect_uri: string;
}

/**
 * トークンエンドポイントのレスポンスパラメータ
 */
interface TokenEndPointResponse {
  access_token: string;
  expires_in: string;
  scope: string;
  token_type: string;
}

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

  // 認可コード
  private code: string;

  constructor(private route: ActivatedRoute,
              private router: Router,
              private http: HttpClient,
              private tokenService: TokenService) { }

  async ngOnInit() {
    // 認可コード,state取得
    let callbackState;
    await this.route.queryParamMap.subscribe( param => {
      this.code = param.get('code');
      callbackState = param.get('state');
    });

    // state検証
    const issuedState = sessionStorage.getItem('state');
    sessionStorage.removeItem('state');
    if (callbackState !== issuedState) {
      alert('state検証に失敗しました');
      await this.router.navigate(['/home']);
      return;
    }

    // アクセストークン取得
    await this.getAccessToken();

    // 画像表示コンポーネントに遷移
    await this.router.navigate(['/photo']);
  }

  /**
   * アクセストークン
   */
  getAccessToken() {
    const  headers = new HttpHeaders();
    headers.append('Content-Type', 'application/x-www-form-urlencoded');
    const options = { headers };

    const body: TokenEndPointRequest = {
      client_id: environment.auth.clientId,
      client_secret: environment.auth.clientSecret,
      grant_type: 'authorization_code',
      code: this.code,
      redirect_uri: environment.auth.redirectUri,
    };

    return new Promise( (resolve, reject) => {
      this.http.post<TokenEndPointResponse>(environment.auth.tokenUri, body, options).subscribe( async data => {
        // アクセストークン保持
        await this.tokenService.setAccessToken(data.access_token);
        resolve();
      });
    });
  }
}
```

別コンポーネントでアクセストークンを利用してGoogle Photo APIを利用するため、
アクセストークンの管理はサービスクラスで管理させます

```typescript:token.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

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

  private accessTokenSubject: BehaviorSubject<string> = new BehaviorSubject('');
  public accessToken = this.accessTokenSubject.asObservable();

  constructor() { }

  /**
   * アクセストークンの更新
   * @param accsessToken アクセストークン
   */
  setAccessToken(accsessToken: string) {
    this.accessTokenSubject.next(accsessToken);
  }
}
```

### Google Photo APIで写真取得

`TokenService`からアクセストークンを取得し、Google Photo APIで画像取得します
今回はサンプルとして一枚の画像のみを表示させるようにします

```typescript:display-photo.component.ts
import {Component, OnInit} from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { TokenService } from '../../service/token.service';
import { environment } from '../../../environments/environment';

/**
 * Google Photos APIのI/F
 */
interface AlbumResponse {
  albums: Album[];
}
interface Album {
  mediaItemsCount: string;
  coverPhotoMediaItemId: string;
  coverPhotoBaseUrl: string;
  id: string;
  title: string;
  productUrl: string;
}

@Component({
  selector: 'app-display-photo',
  templateUrl: './display-photo.component.html',
  styleUrls: ['./display-photo.component.css']
})
export class DisplayPhotoComponent implements OnInit {

  private accessToken: string;
  public imgUrl: string;

  constructor(private http: HttpClient,
              private tokenService: TokenService) {
    // アクセストークンの取得
    this.tokenService.accessToken.subscribe( token => {
      this.accessToken = token;
    });
  }

  ngOnInit() {
    // Google Photos API
    this.getGooglePhotos(this.accessToken);
  }

  /**
   * Google Photos APIで画像取得
   */
  getGooglePhotos(accessToken: string) {
    console.log(accessToken);
    const headers = new HttpHeaders(
      { Authorization: 'Bearer ' + accessToken }
    );
    const options = { headers };
    this.http.get<AlbumResponse>(environment.auth.photosAlbumUri, options).subscribe( data => {
      this.imgUrl = data.albums[0].coverPhotoBaseUrl;
    });
  }
}
```

## 確認

1. `http://localhost:4200/home`にアクセスし、ボタンをクリックします

    |![スクリーンショット 2020-05-14 23.12.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239707/f2353bae-064d-b79c-6546-37a127795a07.png)|
    |:-:|

2. 認可画面にリダイレクトされ、Googleアカウントで認証（認可？）します

    |![google.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239707/902bfd77-941a-a506-ed2c-9f79820b8aec.png)|
    |:-:|

3. アプリケーションにリダイレクトされ、画像表示されました

    |![スクリーンショット 2020-05-14 23.18.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239707/098c24e9-1564-a8bc-18cc-b6b7661cdeac.png)|
    |:-:|


## 補足
今回は`リフレッシュトークン`や`アクセストークンの有効期限`は考慮していません
チュートリアルの流れを実装して、理解を深めるためのメモになります

コードは以下にあります
https://github.com/okome-me/angular-oauth-sample