「Googleでログイン」を実装してみた(その2)

タム

2024.03.27

4

こんにちは。タムです。

今回はGoogleでログインの第2回目ということで、前回ネクストアクションとして掲げていた

インプリシットフローを試してみました。

成果物

ぶっちゃけ見た目的にはほとんど何も変わってないです・・・

設計

インフラ

認可エンドポイントしか使用しないのでバックエンドは不要になりました。

フロー図

認可コードフローの場合は認可エンドポイントから認可コードが返ってきて、それをアクセストークンと交換する必要があったのですが、

インプリシットフローの場合は直接アクセストークンが返ってきます。

前回よりもぐっとシンプルになりました。

実装

認可コードフローが実装できればインプリシットフローはほぼ同じです。

以下、フロント実装の差分です。

diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 659e6e5..910dc9d 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -23,7 +23,31 @@
 		const href = `${discoverRes.data.authorization_endpoint}?response_type=${params.response_type}&client_id=${params.client_id}&scope=${params.scope}&redirect_uri=${params.redirect_uri}&state=${params.state}&nonce=${params.nonce}`;
 		location.href = href;
 	}
+
+	async function implicitLogin() {
+		// ディスカバリドキュメントを参照
+		const discoverRes = await axios.get('https://accounts.google.com/.well-known/openid-configuration');
+		sessionStorage.setItem('discoverDoc', JSON.stringify(discoverRes.data));
+
+		// 認可エンドポイントへのリクエストパラメータ設定
+		const params = {
+			response_type: 'token id_token',
+			client_id: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com',
+			scope: 'openid profile email',
+			redirect_uri: 'https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/implicit/callback',
+			state: Math.random().toString(32).substring(2),
+			nonce: Math.random().toString(32).substring(2)
+		};
+
+		sessionStorage.setItem('state', params.state);
+		sessionStorage.setItem('nonce', params.nonce);
+
+		// 認可エンドポイントへ遷移
+		const href = `${discoverRes.data.authorization_endpoint}?response_type=${params.response_type}&client_id=${params.client_id}&scope=${params.scope}&redirect_uri=${params.redirect_uri}&state=${params.state}&nonce=${params.nonce}`;
+		location.href = href;
+	}
 </script>
 
 <h1>ログイン</h1>
 <p><button on:click={login}>Googleでログイン</button></p>
+<p><button on:click={implicitLogin}>Googleでログイン(Implicit)</button></p>
diff --git a/src/routes/implicit/callback/+page.svelte b/src/routes/implicit/callback/+page.svelte
new file mode 100644
index 0000000..efac8cb
--- /dev/null
+++ b/src/routes/implicit/callback/+page.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+	import { error } from "@sveltejs/kit";
+	import axios, { type AxiosResponse } from "axios";
+	import { onMount } from "svelte";
+	let userInfoRes: AxiosResponse;
+
+    onMount(async () => {
+        const fragment = window.location.hash;
+        const keyValueArgs = fragment.substring(1).split('&');
+        let fragmentArgs: Record<string, string> = {};
+        for (const keyValue of keyValueArgs) {
+            const [key, value] = keyValue.split('=');
+            fragmentArgs[decodeURIComponent(key)] = decodeURIComponent(value);
+        }
+        console.log(fragmentArgs);
+
+		// 送られてきたstateが一致しているか確認
+		const sessionState = sessionStorage.getItem('state');
+
+		if (fragmentArgs.state != sessionState) {
+			error(401, { message: 'Invalid state parameter.' });
+		}
+
+        // userinfoエンドポイントにアクセス
+		const discoverDoc = JSON.parse(sessionStorage.getItem('discoverDoc') ?? '');
+		userInfoRes = await axios.get(discoverDoc.userinfo_endpoint, {headers: {Authorization: `Bearer ${fragmentArgs.access_token}`}});
+		console.log(userInfoRes);
+    });
+</script>
+
+{#if userInfoRes}
+	<h1>{userInfoRes.data?.name}</h1>
+	<img src={userInfoRes.data?.picture} alt="facePicture" />
+{:else}
+	<h1>ログインしています...</h1>
+{/if}


インプリシットフローのセキュリティホールについて

設計も実装もシンプルなインプリシットフローは魅力的に見えてしまうのですが、

OAuth2.0で推奨されているのは認可コードフローの方です。

インプリシットフローではアクセストークンやIDトークンが

直接コールバックエンドポイントのURLのフラグメントとして付与されます。

URLフラグメントはブラウザの履歴に残るため、履歴を閲覧できる立場であれば

過去に発行されたトークンを抜き取ることが簡単にできてしまいます。

対策としては、トークンの有効期限を短めに設定することが重要だと思います。


他にもセキュリティ的に考慮すべき点がいくつかあるようですが、

深掘っていくとそれだけでかなりのボリュームになりそうなので、

別の機会に譲ろうと思います。

まとめ

今回は、OIDCのインプリシットフローを実装してみました。

実装してみて、認証コードフローをしっかりと押さえていれば

よりシンプルな形になっただけなのでかなりハードルが低いように思えました。

この記事をシェアする