OAuth2.0入門 (feat. Next.js)
はじめに
こんにちは、クラウドエースの第3開発部に所属している金です。
本記事の前半では OAuth 2.0 の仕組みを軽く解説し、Next.js のコードを書きながらその動作を理解していきます。
対象読者
- OAuth 2.0 の仕組みを知りたい方
- Next.js で OAuth 2.0 を実装したい方
- NextAuth.js の経験はあるが、OAuth 2.0 の仕組みを理解したい方
- Next.js を経験したことがある方
事前準備
-
Next.js プロジェクトの作成
(本記事では ver. 14.1.4 を使用) -
- 以下の情報を設定
- Application name: 任意の名前
- Homepage URL: 任意の https URL
- Authorization callback URL: http://localhost:3000/api/auth/callback/github
- 以下の情報を設定
-
Nano IDのインストール
OAuth 2.0 とは
OAuth 2.0 に入る前に、まず「認証」と「認可」について簡単に説明します。
認証(Authentication)
本人確認のプロセスです。「あなたは誰ですか?」を確認することです。
- 例:
- SNS に ID とパスワードでログインする
- スマートフォンで指紋認証を使う
認可(Authorization)
アクセス権限を与えるプロセスです。「あなたは何ができますか?」を決めることです。
- 例:
- ホテルにチェックインして部屋の鍵カードをもらう
- アプリに写真へのアクセスを許可する
OAuth 2.0 は、この「認可」の仕組みを標準化したフレームワークです。
OAuth 2.0 の特徴
OAuth 2.0 は以下の特徴を持っています。
- セキュリティ
- 直接的なパスワード共有が不要
- トークンベースの安全なアクセス管理
- 多様なアプリケーション対応
- Web アプリケーション
- モバイルアプリケーション
- デスクトップアプリケーションなど
詳細については、OAuth 2.0を参照してください。
OAuth 2.0 の動作フロー
Web アプリケーションが外部サービスのリソースにアクセスする際には、そのリソースへのアクセス権限を取得する必要があります。
外部サービスのリソースにアクセスする際、ユーザー ID とパスワードによる直接認証には以下のような問題があります。
-
セキュリティ上の問題
- パスワードの漏洩リスク
- アプリケーションがユーザーの認証情報を直接保持してしまう
-
権限管理の問題
- アプリケーションが全ての権限を取得してしまう
- 必要最小限の権限制御が難しい
-
運用上の問題
- 外部サービスごとに認証情報の管理が必要
- パスワード変更時の連携が複雑
OAuth 2.0 は、このような問題を解決し、安全にリソースへのアクセス権限を付与する方法を提供します。
OAuth 2.0 の基本ステップ
OAuth 2.0 の処理は、大きく分けて 2 つのステップで構成されています:
- アクセストークンの取得
- トークンの使用
実践編
今回作成するのは、GitHub のプロフィール写真を表示するシンプルなアプリケーションです。
OAuth2.0 認可フローの流れは以下のようになります:
- ユーザーが Web アプリケーションにアクセスし、ボタンをクリック
- アクセス権限がない場合、GitHub 認可ページへの遷移を確認
- アプリケーションサーバーは、GitHub の認可エンドポイントへの URL を生成
- ブラウザが GitHub の認可ページへリダイレクト
- ユーザーが GitHub にログイン(未ログインの場合)
- GitHub がアプリケーションの要求する権限スコープを表示
- この場合:プロフィール写真へのアクセス権限(read:user)
- ユーザーが権限を承認すると、GitHub は認可コードを発行
- 事前に設定したコールバックURLへ認可コードと共にリダイレクト
認可フローのイメージは以下のようになります。
サンプルコードのディレクトリ構造は以下です。
├── app
│ ├── api
│ │ └── auth
│ │ └── callback
│ │ └── github
│ │ └── route.js
│ │ └── profile
│ │ └── route.js
│ ├── page.js
│ └── utils
│ └── createOrGetsession.js
メイン画面の実装
メイン画面の実装は以下のようになります。
シンプルなボタンを配置しています。
// /app/page.js
"use client";
import { useEffect, useState } from "react";
import "./main.css";
import axios from "axios";
export default function Home() {
const [profileImage, setProfileImage] = useState();
useEffect(() => {
// URLパラメータを取得するためのオブジェクトを作成
const searchParams = new URLSearchParams(window.location.search);
// 認可状態を確認( URL の auth パラメータを取得)
const authStatus = searchParams.get("auth");
if (authStatus === "success") {
// 認可が成功した場合、プロフィール画像を読み込む
fetchProfileImage();
}
}, []);
// プロフィール画像を取得する関数
const fetchProfileImage = async () => {
try {
// プロフィール API 呼び出し
const { data } = await axios.get("/api/profile");
// プロフィール画像を保存
setProfileImage(data.profile);
} catch (error) {
console.log("Error:", error.response);
// 401 エラーの処理
if (error.response?.status === 401) {
if (window.confirm("権限がありません。認可を行いますか?")) {
// 認可ページへリダイレクト
window.location.assign("/api/auth");
}
}
}
};
// ボタンクリック時の処理
const handleClick = () => {
fetchProfileImage();
};
return (
<main>
<h1>OAuth Test</h1>
<div>
<p>Github OAuth Test</p>
{/* プロフィール画像リクエストボタン */}
<button onClick={handleClick} type="button">
get profile image
</button>
</div>
{/* プロフィール画像の条件付きレンダリング */}
{profileImage ? (
<div className="box">
<span style={{ backgroundImage: `url(${profileImage})` }} />
</div>
) : 'no image'}
</main>
);
}
「get profile image」ボタンをクリックすると、以下のような流れで処理が進みます。
- まず、プロフィール画像を取得するためのエンドポイント(/api/profile)にリクエストが送られます。
- 初回アクセス時は session が存在しないため、401エラーが返されます。
- 401エラーを受けて、以下のような認可確認のポップアップが表示されます。
- 「OK」ボタンをクリックすると、/api/authエンドポイントを経由して GitHub の認可画面へリダイレクトされます。
プロフィール情報取得 API
// /app/api/profile/route.js
import axios from "axios";
import { createOrGetServerSession } from "@/app/utils/createOrGetsession";
export const GET = async () => {
// セッション情報を取得
// * createOrGetServerSession 実装の詳細は後述
const session = await createOrGetServerSession();
// アクセストークンが存在しない場合は未認可エラーを返す
if (!session.accessToken) {
return Response.json(
{
message: "権限がありません。",
},
{
status: 401,
},
);
}
// 認可後、GitHub の APIを使用してユーザー情報を取得
const { data } = await axios.get("https://api.github.com/user", {
headers: {
accept: "application/json",
// アクセストークンを Authorization ヘッダーに設定
Authorization: `Bearer ${session.accessToken}`,
},
});
// プロフィール画像の URL を返す
return Response.json({
profile: data.avatar_url,
});
};
GitHub 認可リダイレクト API
// /api/auth/route.js
export const GET = async () => {
const url = 'https://github.com/login/oauth/authorize'
+ `?client_id=${process.env.CLIENT_ID}`
+ '&scope=read:user'
+ '&redirect_uri=http://localhost:3000/api/auth/callback/github';
// GitHub 認可画面へリダイレクト
return Response.json({}, {
status: 307, // 307 Temporary Redirect レスポンスを返す
headers: {
Location: url, // リダイレクト先の URL を指定
},
});
};
認可のためのパラメータを設定:
- client_id: GitHub OAuth App の Client ID
- scope: 要求する権限の範囲(ここではユーザー情報の読み取り権限)
- redirect_uri: 認可後にリダイレクトされるコールバックURL
詳細の内容についてはAuthorizing OAuth appsを参照してください。
上記の認可を実装すると GitHub App で設定した redirect_url にリダイレクトされます。
認可コールバック API
GitHub 認可プロセスの最後のステップとして、認可コードをアクセストークンと交換する API です。
// /app/api/auth/callback/github/route.js
import axios from 'axios';
import { createOrGetServerSession } from '@/app/utils/createOrGetsession';
export const GET = async (request) => {
// セッション情報を取得または作成
// * createOrGetServerSession 実装の詳細は後述
const session = await createOrGetServerSession();
// GitHub からリダイレクトされた際の URLパラメータから認可コードを取得
const code = request.nextUrl.searchParams.get('code');
// GitHubのトークンエンドポイントにリクエストを送信
const { data } = await axios.post('https://github.com/login/oauth/access_token', {
// 認可コード
code,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
}, {
headers: {
accept: 'application/json',
},
});
// 取得したアクセストークンをセッションに保存
session.accessToken = data.access_token;
// // アプリケーションのトップページにリダイレクト
return Response.json({}, {
status: 307,
headers: {
Location: '/',
},
});
};
GitHub OAuth の認可フローが終わったら、以下の画面に戻ります。
「get profile image」ボタンをクリックすると、GitHub API を使用してユーザー情報を取得し、プロフィール画像が表示されます。
session 管理の実装
OAuth 2.0 の認可フローで取得したアクセストークンを安全に保持するため、サーバーサイドでセッション管理を実装します。
この実装はデモ用の簡易的なものであり、実運用では Redis などの永続的なストレージの使用を推奨します。
セッション管理の主な機能
- セッションの作成と取得
- クッキーベースのセッション識別
- メモリ内でのセッションデータ保存
- Proxy によるセッション更新の自動化
//app/utils/createOrGetsession.js
import { cookies } from 'next/headers';
import { nanoid } from 'nanoid';
// セッションを識別するためのクッキーのキー名
const SESSION_KEY = 'sessionId';
// セッションの有効期限(7日間)
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
// メモリ内のセッションストア
// 注意: サーバーの再起動時にすべてのセッションが失われます
const sessions = new Map();
export const createOrGetServerSession = async () => {
// クッキーからセッションキーを取得、存在しない場合は新規生成
const sessionKey = cookies().get(SESSION_KEY)?.value ?? nanoid();// nanoidを使用して一意のセッションIDを生成
const session = sessions.get(sessionKey);
// セッションが存在しない場合、新規作成
if (!session) {
const value = {
cookie: {
httpOnly: true, // true: クライアントサイドの javaScript から document.cookieを介したクッキーへのアクセスを防止
secure: false, // false: HTTP/HTTPS両方でクッキーの送信を許可(開発環境用)
maxAge: SESSION_MAX_AGE,
path: '/', // クッキーが有効なパス('/'は全てのパスで有効)
},
createdAt: Date.now(),
};
sessions.set(sessionKey, value);
cookies().set(SESSION_KEY, sessionKey, value.cookie);
}
// Proxyを使用してセッションオブジェクトの変更を監視し、自動的にストレージに更新
// https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy
return new Proxy(sessions.get(sessionKey), {
// setトラップ: オブジェクトのプロパティが変更される時に呼び出される
set(target, prop, value) {
target[prop] = value;
sessions.set(sessionKey, target);
return true;
},
});
};
セキュリティ問題
// /api/auth/route.js
export const GET = async () => {
const url = 'https://github.com/login/oauth/authorize'
+ `?client_id=${process.env.CLIENT_ID}`
+ '&scope=read:user'
+ '&redirect_uri=http://localhost:3000/api/auth/callback/github';
セキュリティの脆弱性について
気づいた方もいらっしゃるかもしれませんが、このコードにはセキュリティ上の問題があります。
以下の URL を奪われると、アクセストークンが奪われてしまいます。
※ 上記の URL でテストするときは、client_id をご自身の Client ID に変更してください。
例えば、この redirect_uri を redirect_uri=http://localhost:3000/api/auth/callback/github/attack に変更すると、以下の例のように認可コード (code) を不正に取得することができてしまいます。
// 攻撃イメージ
http://localhost:3000/api/auth/callback/github/attack?code=xxxxxxx
// /app/api/auth/callback/github/attack/page.js
'use client';
import { useSearchParams } from 'next/navigation';
// CSRF攻撃のデモンストレーション用ページ
// 攻撃シナリオ:
// 1. 正規の認可URLを改ざんする
// 2. redirect_uriを攻撃者のページに変更する
// 例:https://github.com/login/oauth/authorize?client_id=xxxxx&scope=read:user&redirect_uri=http://localhost:3000/api/auth/callback/github/attack
export default function AttackerPage() {
// URL パラメータから認可コード (code) を取得
const searchParams = useSearchParams();
const code = searchParams.get('code');
return (
<main>
<h1>CSRF 攻撃者のページ</h1>
<div>
<p>get! code: {code}</p>
</div>
</main>
);
}
code=xxxxxxx
を以下のように使うとリソースにアクセスできてしまいます。
http://localhost:3000/api/auth/callback/github?code=xxxxxxx
この脆弱性により、攻撃者は以下のような不正が可能になります。
- 正規ユーザーの認可コードの傍受
- 不正なリソースアクセスの許可
- ユーザーになりすました API 利用
このような攻撃手法はクロスサイトリクエストフォージェリ (CSRF:Cross-Site Request Forgery) として知られています。
CSRF は、ユーザーの意図しない操作を強制的に実行させる攻撃手法の一つです。
CSRF の対策としては、既存のコードに state パラメータにランダムの文字列を追加します。
state を追加して GitHub の認可を行うと、認可が終わった時に state をそのまま返します。
ご参考: authorizing-oauth-apps: Query parameter
認可後、コールバック API では、state をチェックして、認可が正しいかどうかを確認します。
これにより、CSRF の脆弱性を防ぐことができます。
既存のコードに state を追加したものが以下です。
// /api/auth/route.js
import { createOrGetServerSession } from "@/app/utils/createOrGetsession";
import { nanoid } from "nanoid";
export const GET = async () => {
// ランダムな文字列を生成
const stateVerifier = nanoid();
// セッションを作成
const session = createOrGetServerSession();
// 生成したstateをセッションに保存(後でコールバック時に検証するため)
session.state = stateVerifier;
const url = 'https://github.com/login/oauth/authorize'
+ `?client_id=${process.env.CLIENT_ID}`
+ '&scope=read:user'
+ '&redirect_uri=http://localhost:3000/api/auth/callback/github'
+ `&state=${state}`;
return Response.json({}, {
status: 307,
headers: {
Location: url,
},
});
};
コールバック API は以下のように修正します。
// /app/api/auth/callback/github/route.js
import axios from 'axios';
import { createOrGetServerSession } from '@/app/utils/createOrGetsession';
export const GET = async (request) => {
const session = await createOrGetServerSession();
const code = request.nextUrl.searchParams.get('code');
// state をチェックして CSRF 攻撃を防ぐ
// state が一致しない場合はエラーページにリダイレクト
// 以下チェツクがない場合、奪った code でリソースにアクセスできるようになる
const state = request.nextUrl.searchParams.get("state");
if (!state || session.state !== state) {
return Response.json({}, {
status: 307,
headers: {
Location: '/error',
},
});
}
const { data } = await axios.post('https://github.com/login/oauth/access_token', {
code,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
}, {
headers: {
accept: 'application/json',
},
});
session.accessToken = data.access_token;
return Response.json({}, {
status: 307,
headers: {
Location: '/',
},
});
};
奪われた code=xxxxxxx
でアクセスすると、/error にリダイレクトされるようになります。
http://localhost:3000/api/auth/callback/github?code=xxxxxxx
※ ここで紹介したコードは、OAuth 2.0の基本的な仕組みを理解するための学習用実装です。
本番環境での使用は推奨されません。
まとめ
この記事では、以下の内容について説明してきました。
- GitHub の OAuth 認可の基本的な流れ
- セキュリティ対策の重要性(CSRF)
実際のプロダクション環境では、セキュリティとメンテナンス性を考慮して、NextAuth.js などの実績のある認証・認可ライブラリの使用を推奨します。
NextAuth.js は、OAuth 2.0 認可フローを含む包括的な認証ソリューションを提供しています。
NextAuth.js を使用すると、わずか数行のコードで OAuth 2.0 認可を実装できます。
// NextAuth.js を使用した実装例
import NextAuth from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
export const authOptions = {
providers: [
GithubProvider({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
}),
callback: {
async session({ session, token }) {
session.accessToken = token.accessToken;
return session;
},
},
//...options
],
};
export default NextAuth(authOptions);
私も以前は、NextAuth.js などのライブラリを仕組みを理解しないまま使用していました。
この記事を通じて、認証・認可の基本的な仕組みについて、少しでも理解を深めるきっかけになれば嬉しく思います。
最後までお読みいただきありがとうございました。
Discussion