注意:Public ClientであるSPAで認可コードグラント
を実装していますが、実際には認可コードグラント + PKCE
が推奨となります。
2020/5/15 stateの検証を追加しました。
「雰囲気で使わずきちんと理解する!整理してOAuth2.0を使うためのチュートリアルガイド」 の「第6章 チュートリアル」の認可コードグラント
での認可をAngular
で実装してみます
OAuthの理解は以下記事もとても参考になります。
「OAuth 2.0 全フローの図解と動画」
本書チュートリアルでは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でAPIの設定をします
クライアントID、クライアントシークレットの取得方法の詳細は割愛しますが、以下を実施してください
-
Photos Library API
の有効化 - コールバックURIを
http://localhost:4200/callback
に設定
Googleフォトに画像のアップロード
https://www.google.com/intl/ja/photos/about/
Googleフォトの画像を表示する実装のため、画像をアップロードします
実装
ディレクトリ構造は以下です
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情報の管理
処理の手順としては以下です
- 認可エンドポイントにリクエストを送る(
home
) - (Googleの)認可画面にリダイレクトされ、Googleの認証情報を入力する
- 認可コードがコールバックされる(
callback
) - 認可コードを利用してトークエンドポイントにリクエストを送り、アクセストークンを取得する(
callback
) - アクセストークンを利用してGoogle Photo APIで写真取得する
OAuth情報
準備したクライアントID,クライアントシークレットを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'
}
};
ルーティング設定
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さん コメントでのご指摘ありがとうございます)
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
に返ってきます
認可コードをもとにトークエンドポイントにリクエストを送り、アクセストークンを取得します
/**
* トークンエンドポイントのリクエストパラメータ
*/
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を利用するため、
アクセストークンの管理はサービスクラスで管理させます
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で画像取得します
今回はサンプルとして一枚の画像のみを表示させるようにします
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;
});
}
}
確認
-
http://localhost:4200/home
にアクセスし、ボタンをクリックします -
認可画面にリダイレクトされ、Googleアカウントで認証(認可?)します
-
アプリケーションにリダイレクトされ、画像表示されました
補足
今回はリフレッシュトークン
やアクセストークンの有効期限
は考慮していません
チュートリアルの流れを実装して、理解を深めるためのメモになります
コードは以下にあります
https://github.com/okome-me/angular-oauth-sample