LoginSignup
1
0

More than 1 year has passed since last update.

【後編】OAuth2.0の勉強でAuthorization Code Grantをクライアント側として体感してみた

Last updated at Posted at 2022-02-01

はじめに

本記事は「【前編】OAuth2.0の勉強でAuthorization Code Grantをクライアント側として体感してみた」の後編にあたる記事です。まずは前編として【前編】OAuth2.0の勉強でAuthorization Code Grantをクライアント側として体感してみたを参照ください。

以下、前編の「はじめに」に書いた内容と同じです。

==============================================================
認証と認可に関しては、OpenID Connectがデファクトな状況だと思われる。今回はOAuth2.0を拡張した仕様であるOpenID Connectで定義されている、認可のフローであるAuthorization Code Grantについて、認可をもらう側(クライアント側)としてフローを実行する事で、理解を深めてみたいと思う。

流れとしては、

  1. 認可エンドポイントへ認可リクエスト
  2. 認証(今回はGoogleのOpenID Connectを使うので、Googleの認証)と認可
  3. リダイレクトするので、それをserverで受け取りトークンエンドポイントへアクセストークンをリクエスト

の中の、1と3の部分を実際に実装する。
※今回はあえてCSRF対策やPKCE対策に必要になるパラメータを省略している。CSRF対策やPKCE対策については今後記事を執筆予定。

※今回、Google Cloud PlatformでGoogle APIsの設定をしてAuthorization Code Flowを体感する。その際料金はかからない想定で本記事は書いている(無料枠があるので)。詳細はGoogle Cloud の無料プログラムを参照。ただし、Authorization Code Flowで取得したアクセストークンを使ってAPIを呼び出すが、その際に選んだAPIによっては課金される場合もあるため注意。本記事の内容を実践して万が一課金されても責任は負えません。Google Cloud Platformの利用にかかる課金やその他の事由については自己責任でお願い致します。)

※本記事中で筆者の理解に誤りがあればご指摘頂けると幸いです。

※今回、文章量が多くなってしまったため、記事を前編と後編の2つに分割した。前編では、「OpenID Connectとは?という話からAuthorization Code Flowを体感するための事前準備まで」を書いている。後編では、「実際にクライアント側としてAuthorization Code Flowを実装し、アクセストークンを用いてAPIを実行するまで」を書いている。

ソースコード全体は以下。

==============================================================

実際に実装してみる

今回はWebアプリケーションを想定して、Node.jsのExpressでアプリを構築してみる。フロントエンドは特にアプリケーションとしての機能を作りたい訳ではないので簡素に実装する(Vue.js・Vuetifyで見た目は少しきれいになるようにするが)。

実装するものとしては、

  • 認可エンドポイントへ認可リクエストを行う部分
  • ユーザの認可後の認可レスポンスでredirectする先となるエンドポイント
  • アクセストークンを使ってCalendarList: list を実行する部分

の3つ。

※実際のプロダクトで使えるような実装ではないので注意。あくまでAuthorization Code Flowを体感するためのサンプル実装。

実装したコードの全体としては以下を参照。

認可エンドポイントへ認可リクエストを行う部分

画面からユーザが認可を行う(具体的にはGoogle Calendarとの連携を開始する)ためのボタンを作成する。ボタンをクリックすると、サーバサイドで認可リクエストのためのURLを作成し、それを302のリダイレクトでフロントエンドに返す事で、認可リクエストを行うという流れ。
image.png

コードとしては以下。

index.ejs
<!DOCTYPE html>
<html lang="ja">
	<head>
		...
	</head>
	<body>
		<v-app id="app">
			...

			<v-main>
				<v-container class="fill-height justify-center">
					<v-card>
						<form action="/begin" method="GET">
							<v-card-title>
								Google Calendarとの連携(認可リクエストを行う)
							</v-card-title>
							<v-card-text>
								<p>(例として…)</p>
								<p>
									Google Calendarとの連携を行うには、連携をクリックしてください
								</p>
							</v-card-text>
							<v-card-actions>
								<v-spacer></v-spacer>
								<v-btn type="submit" color="primary">
									連携する(認可リクエスト送る)
								</v-btn>
							</v-card-actions>
						</form>
					</v-card>
				</v-container>
			</v-main>
			...
		</v-app>

		<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
		<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
		<script>
			new Vue({
				el: '#app',
				vuetify: new Vuetify(),
				data: () => ({
					...
				})
			});
		</script>
	</body>
</html>
server.js
import axios from 'axios';
import express from 'express';
import config from 'config';
import qs from 'qs';
...

app.get('/begin', async (req, res) => {
	const { data: openidConfig } = await axios.get(config.get('discovery'));

	const params = {
		client_id: process.env.CLIENT_ID,
		response_type: 'code',
		scope: config.get('authRequest.scopes').join(' '),
		redirect_uri: config.get('redirectUri')
	};

	res.redirect(
		`${openidConfig.authorization_endpoint}?${qs.stringify(params)}`
	);
});
{
	"discovery": "https://accounts.google.com/.well-known/openid-configuration",
	"authRequest": {
		"scopes": [
			"https://www.googleapis.com/auth/calendar.readonly",
			"https://www.googleapis.com/auth/calendar"
		]
	},
	"redirectUri": "https://example.com:8080/oauth2/callback"
}

一部、ソースコードについて補足をする。

await axios.get(config.get('discovery'))

今回アクセストークンを払い出すGoogleの認証・認可サーバは、OpenID Connectの仕様を満たすものとして実装されており、OpenID Connectの仕様の中にはOpenID Connect Discovery 1.0という仕様も含まれている。このdiscoveryエンドポイントからはOpenID Providerのプロバイダー情報をJSONで取得できるので、プロバイダー情報内の認可エンドポイントの情報を取得するためにgetを行っている。

ちなみに、どのような情報が取得できるか?は3. OpenID Provider Metadataに書かれているものが全量で、Googleの場合にはThe Discovery documentに記載がある。

※認可エンドポイントは常に固定というものではなく、可変なため毎回discoveryエンドポイントから最新の情報を取得するのが望ましいようである。

scope

CalendarList: list Authorizationを見ると必要なscopeは以下の2つなので、それぞれをscopeに指定している。

また、Authentication URI parametersに書かれているように、scopeは半角スペースで区切る必要があるので注意(。

All scope values must be space-separated.(すべてのスコープ値はスペースで区切る必要があります。)

ちなみに、OpenID Connectの方(5.4. Requesting Claims using Scope Values)では、

Multiple scope values MAY be used by creating a space delimited, case sensitive list of ASCII scope values.(複数のスコープ値を使用するには、スペースで区切られたASCIIスコープ値のリストを作成すればよい(MAY)。)

となっており、半角スペース区切りにするのは必須とは定義されていないのでOpenID Providerによりscopeが複数の場合の扱いは違う可能性があるのだろう。

const params = {...}

今回はscopeにopenidがないので、(基本的に)RFC6749の仕様に従うパターンになる。RFC6749の4.1.1. Authorization Requestに書かれている仕様に沿ってクエリパラメータを設定するためのJSONを定義している(JSONをクエリパラメータに変換するにはStringifying(qs.stringify(params))を利用)。

各キーに関する説明としては以下の通り。

  • response_type
    認可サーバに何を返してほしいのか?など、使用する認証処理フローを決定するための項目。必須。今回はAuthorization Code Flowでアクセストークンを払い出す認可フローなのでcodeになる。RFC6749にも*Value MUST be set to "code".(値は "code "に設定しなければならない(MUST)。)*と書かれている。
  • redirect_uri
    認可レスポンスで、そのレスポンスのLocationに設定されるURIを指定するための項目。今回はWebアプリなので、認可をリクエストするWebアプリのサーバのエンドポイントになる。これはOpenID Connectの仕様では必須となっているが、RFC6749では任意となっている。ちょっとこの辺りでどちらの仕様になるのか?が理解できていないが、Googleの場合、その動きを見ている限りOpenID Connectの必須の仕様で動いているようである(redirect_uriを指定しないで認可リクエストを行うと400エラーになった)。
    image.png
  • client_id
    認可サーバ側に登録しているクライアント(今回はWebアプリ)を識別するためのIDを設定する項目。これはもちろん必須。ここでクライアントを作成するにて登録したclient_idが使われる。
  • scope
    認可を得たい権限を列挙して設定する項目。任意。今回はGoogleなのでOAuth 2.0 Scopes for Google APIs に全てのscopeが列挙されている。

※今回は最低限のキーしか設定しないが、実際には10.12. Cross-Site Request Forgeryの対策としてのstateや、RFC7636の4.3. Client Sends the Code Challenge with the Authorization Requestに書かれているようにPKCE対策のためのcode_challenge・code_challenge_methodを追加する事が推奨されている。

※scopeにopenidがない場合にはRFC6749になるというのは、3.1.2.1. Authentication Requestscopeに、

OpenID Connect requests MUST contain the openid scope value. If the openid scope value is not present, the behavior is entirely unspecified.(OpenID Connectリクエストはopenidスコープ値を含まなければなりません(MUST)。openid スコープ値が存在しない場合、動作は全く規定されていません。)

と書かれており、scopeにopenidがない場合にはOpenID Connectの仕様としては何も決まりがない=RFC6749になるという事だと思っている(参考情報として、1. response_type=codeには

response_type の値が code の場合、これだけでは RFC 6749 の認可コードフローと何も変わりません。

と書かれており、また、認可リクエストの資料の中のRFC 6749 か OIDC かの動的判定の部分で、

scope パラメーターに openid が含まれていれば OIDC リクエストとみなす

と書かれている)。

ユーザの認可後の認可レスポンスでredirectする先となるエンドポイント

認可リクエストを行うと、以下のような画面が表示されて、ユーザは認可を行う。

Googleにログイン(ID) Googleにログイン(パスワード) テストアプリの旨 認可
image.png image.png image.png
※今回のクライアント(Webアプリ)はOAuth同意画面を設定するでテストモードで作成しているので上記のような警告が表示される
image.png

上記の「認可」でContinueを実行する=ユーザの認可が完了すると、Googleの認証・認可サーバから認可レスポンスが返ってくるが、その認可レスポンスのリダイレクト先となるエンドポイントの実装がここでの実装になる。ソースコードとしては以下のような感じ。

server.js
import axios from 'axios';
import express from 'express';
import config from 'config';
import camelcaseKeys from 'camelcase-keys';
import AccessToken from './lib/access-token';
...
app.locals.AccessToken = new AccessToken();
...
app.get('/oauth2/callback', async (req, res) => {
	// eslint-disable-next-line no-shadow
	const { AccessToken } = req.app.locals;

	try {
		const { data: openidConfig } = await axios.get(config.get('discovery'));

		const params = new URLSearchParams();
		params.append('client_id', process.env.CLIENT_ID);
		params.append('client_secret', process.env.CLIENT_SECRET);
		params.append('grant_type', 'authorization_code');
		params.append('code', req.query.code);
		params.append('redirect_uri', config.get('redirectUri'));

		const { data } = await axios.post(openidConfig.token_endpoint, params);
		const camelCaseData = camelcaseKeys(data);
		AccessToken.setToken(camelCaseData.accessToken);

		res.render('./redirect.ejs', camelCaseData);
	} catch (error) {
		res.end(error.message);
	}
});
access-token.js
export default class AccessToken {
	constructor() {
		this.token = null;
	}

	setToken(token) {
		this.token = token;
	}

	getToken() {
		return this.token;
	}
}
redirect.ejs
<!DOCTYPE html>
<html lang="ja">
	<head>
		...
	</head>
	<body>
		<v-app id="app">
			...

			<v-main>
				<v-container>
					<v-row>
						<v-col cols="8">
							<v-card>
								<v-card-title>
									Google Calendarへのアクセストークン
								</v-card-title>
								<v-card-text>
									<v-simple-table>
										<template v-slot:default>
											<thead>
												<tr>
													<th class="text-left">key</th>
													<th class="text-left">value</th>
												</tr>
											</thead>
											<tbody>
												<tr>
													<td>アクセストークン(access_token)</td>
													<td><%= accessToken %></td>
												</tr>
												<tr>
													<td>有効期間(expires_in)(単位は秒)</td>
													<td><%= expiresIn %></td>
												</tr>
												<tr>
													<td>スコープ(scope)</td>
													<td><%= scope %></td>
												</tr>
												<tr>
													<td>トークン種別(token_type)</td>
													<td><%= tokenType %></td>
												</tr>
											</tbody>
										</template>
									</v-simple-table>
								</v-card-text>
								<v-card-actions>
									<v-spacer></v-spacer>
									<v-btn color="primary" @click="getCalendarList">
										実際にAPIを実行
									</v-btn>
								</v-card-actions>
							</v-card>
						</v-col>
						<v-col cols="8" v-if="items">
							<v-card>
								<v-card-title> CalendarList: listの結果 </v-card-title>
								<v-card-text>
									<p v-for="item of items">
										{{ item.summary }}:{{ item.description }}
									</p>
								</v-card-text>
							</v-card>
						</v-col>
					</v-row>
				</v-container>
			</v-main>
			...
		</v-app>

		<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
		<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
		<script>
			new Vue({
				el: '#app',
				vuetify: new Vuetify(),
				data: () => ({
					...
					items: null
				}),
				methods: {
					async getCalendarList() {
						try {
							const response = await fetch('/calendarList', { method: 'GET' });
							const { items } = await response.json();
							this.items = items;
							console.log(this.items);
						} catch (error) {
							console.log(error);
						}
					}
				}
			});
		</script>
	</body>
</html>

実際に認可レスポンスを受けて画面が描画されると以下のようになる。

image.png

一部、ソースコードについて補足をする。

const params = new URLSearchParams();

RFC6749の4.1.3. Access Token Requestに書かれている仕様に沿ってパラメータ(エンコードされたクエリパラメータ)を設定している。

各キーに関する説明としては以下の通り。

grant_type

認可グラント(認可コードの形式)を設定する項目。必須。今回はAuthorization Code Flow(Grant)なのでauthorization_codeになる(RFC6749にも、*Value MUST be set to "authorization_code".(値は "authorization_code "に設定されなければならない(MUST)。)*と書かれている)。

code

認可レスポンスに含まれている、認可サーバから渡される一時的なコードを設定する項目。必須。このコードと引き換えにアクセストークンを払い出してもらう。

redirect_uri

認可リクエストの際に指定したredirect_uriと同じものを指定する。認可リクエスト時にredirect_uriを設定している場合、必須(RFC6749に*if the "redirect_uri" parameter was included in the authorization request as described in Section 4.1.1, and their values MUST be identical.(セクション4.1.1で述べられているように、「redirect_uri」パラメータが 認可リクエストに含まれていた場合、それらの値は同一でなければならない [MUST])*と書かれている)。

client_id

認可リクエストの際に指定したclient_idと同じで、認可サーバ側に登録しているクライアント(今回はWebアプリ)を識別するためのIDを設定する項目。必須。

client_secret

認可サーバでクライアントを認証するために必要になる値を設定する項目。この項目はRFC6749の4.1.3. Access Token Requestには特に記載がないように思えるが、よく読むと

If the client type is confidential or the client was issued client credentials (or assigned other authentication requirements), the client MUST authenticate with the authorization server as described in Section 3.2.1.(クライアントのタイプが機密である場合、またはクライアントがクライアント認証情報を発行された(または他の認証要件を割り当てられた)場合、クライアントはセクション 3.2.1 に記述されているように認可サーバーで認証されなければならない[MUST]。)

と書かれているように、クライアントの認証方式によってはトークンエンドポイントへリクエスト(POST)する際に追加で必要になる項目がある事が分かる。今回はどうなのか?を確かめるために、まず、RFC6749の3.2.1. Client Authenticationを読むと、

Confidential clients or other clients issued client credentials MUST authenticate with the authorization server as described in Section 2.3 when making requests to the token endpoint.(機密保持クライアントまたはクライアント認証情報を発行された他のクライアントは、トークン・エンドポイントにリクエストを行う際に、セクション2.3の説明に従って認可サーバーで認証を行わなければならない(MUST))

と書かれており、今回はクライアント認証情報を発行されたに該当する(【前編】のクライアントを作成するでclient_secretが発行されている)ので、今度はRFC6749の2.3. Client Authenticationを読むと、

the client and authorization server establish a client authentication method suitable for the security requirements of the authorization server. The authorization server MAY accept any form of client authentication meeting its security requirements.(クライアントと認可サーバーが、認可サーバーのセキュリティ要件に適したクライアント認証方法を確立すること。認可サーバーは、そのセキュリティ要件を満たすいかなる形式のクライアント認証も受け入れてもよい(MAY))

となっており、2.3.1. Client Passwordにclient_sercretの事が書かれている。

今回のGoogleの認可サーバは、OpenId Discoveryのエンドポイントから取得できるプロバイダー情報のtoken_endpoint_auth_methods_supportedを見ると、そのクライアントの認証方式はclient_secret_post or client_secret_basicの2ついずれかである事が分かる。その事からも、クライアントの認証のためにclient_secretが必須で必要であり、client_secret_postで対応可能なので、クエリパラメーターにclient_secretを含める実装をしている。詳細は参考に示したOAuth 2.0 クライアント認証を参照。

※当たり前だが、Googleの公式ページ4. Exchange code for access token and ID tokenにもトークンエンドポイントへのリクエストで何が必要か?は書かれている。

※ちなみに、クライアントの認証についてOpenID Connectの仕様としては9. Client Authenticationに書かれている。

axios.post(openidConfig.token_endpoint, params);

axiosの公式を読むとquerystringに関しての記述があるが、これはquerystringの方でURLSearchParamsが推奨となっているので注意が必要だろう(以下、公式からの引用)。

The querystring API is considered Legacy. While it is still maintained, new code should use the API instead.(クエリストリングAPIはレガシーとみなされています。まだ保守されていますが、新しいコードでは代わりに API を使用する必要があります。)

アクセストークンを使ってCalendarList: list を実行する部分

ここまでの実装でアクセストークンを取得する部分まではできたので、ここでいよいよそのアクセストークンを使ってAPIを実行する部分を実装してみる。

Google Calendar APIを実行するためのアクセストークンを取得した(scopeの指定の部分(scope)[#scope]を参照)ので、CalendarList: list を参照して、APIの呼び出しを実装していく。

ソースコードとしては以下(ここではユーザの認可後の認可レスポンスでredirectする先となるエンドポイントで見た画面の「実際にAPIを実行」というボタンをクリックすると呼ばれるREST APIのエンドポイントを実装している)。

server.js
app.get('/calendarList', async (req, res) => {
	// eslint-disable-next-line no-shadow
	const { AccessToken } = req.app.locals;

	try {
		const { data } = await axios.get(
			'https://www.googleapis.com/calendar/v3/users/me/calendarList',
			{
				headers: {
					Authorization: `Bearer ${AccessToken.getToken()}`
				}
			}
		);

		res.status(200).json(data);
	} catch (error) {
		console.log(error);
		res.status(500).json(error.message);
	}
});
redirect.ejs
<!DOCTYPE html>
<html lang="ja">
	<head>
		...
	</head>
	<body>
		<v-app id="app">
			...

			<v-main>
				<v-container>
					<v-row>
						<v-col cols="8">
							...
						</v-col>
						<v-col cols="8" v-if="items">
							<v-card>
								<v-card-title> CalendarList: listの結果 </v-card-title>
								<v-card-text>
									<p v-for="item of items">
										{{ item.summary }}:{{ item.description }}
									</p>
								</v-card-text>
							</v-card>
						</v-col>
					</v-row>
				</v-container>
			</v-main>
			...
		</v-app>

		<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
		<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
		<script>
			new Vue({
				el: '#app',
				vuetify: new Vuetify(),
				data: () => ({
					drawer: false,
					dumyMenus: ['Foo', 'Bar', 'Fizz', 'Buzz'],
					items: null
				}),
				methods: {
					async getCalendarList() {
						try {
							const response = await fetch('/calendarList', { method: 'GET' });
							const { items } = await response.json();
							this.items = items;
						} catch (error) {
							console.log(error);
						}
					}
				}
			});
		</script>
	</body>
</html>

実行した結果は以下の通り。
image.png

実際にGoogle Calendarの方を見てみると、3つのカレンダーが存在しており、それらがAPIで取得できている事が確認できる(グレーで塗りつぶしているものは、primaryカレンダー(googleのアカウントIDに紐づくカレンダー))。

Google Calendarの画面 Google Calendarの設定画面
image.png image.png

一部、ソースコードについて補足をする。

AccessToken.getToken()

今回は簡易的にAPIを実行したいだけだったので、単純にAccessTokenClassのインスタンスからアクセストークンを取り出すような実装をしている(ユーザの認可後の認可レスポンスでredirectする先となるエンドポイントの章で取り上げたserver.jsAccessToken.setToken(camelCaseData.accessToken);でインスタンスのfieldtokenにアクセストークンをsetしている)。

ただこの実装はプロダクトでは絶対にNGなので注意(ユーザ(ブラウザ)をサーバ側で把握する実装になってない)。プロダクトであれば、Redis等にアクセストークンを保存し、CookieにsidのようなsessionIdを渡して、リクエストが来た際にはCookieのsidからユーザ特定をしてアクセストークンを取り出すような実装が良くあるパターンな気がする。

まとめ

前編・後編までに長くなってしまったが、Authorization Code Grant(Flow)について、クライアント側としての動きを体感できた。今回はあえてCSRF対策やPKCE対策をしないでフローを実行していたので、今後その対策の必要性と実際の対策のための実装をやってみたいと思う。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0