🦔

OAuth2.0入門 (feat. Next.js)

2025/02/20に公開

はじめに

こんにちは、クラウドエースの第3開発部に所属している金です。
本記事の前半では OAuth 2.0 の仕組みを軽く解説し、Next.js のコードを書きながらその動作を理解していきます。

対象読者

  • OAuth 2.0 の仕組みを知りたい方
  • Next.js で OAuth 2.0 を実装したい方
  • NextAuth.js の経験はあるが、OAuth 2.0 の仕組みを理解したい方
  • Next.js を経験したことがある方

事前準備

OAuth 2.0 とは

OAuth 2.0 に入る前に、まず「認証」と「認可」について簡単に説明します。

認証(Authentication)

本人確認のプロセスです。「あなたは誰ですか?」を確認することです。

  • 例:
    • SNS に ID とパスワードでログインする
    • スマートフォンで指紋認証を使う

認可(Authorization)

アクセス権限を与えるプロセスです。「あなたは何ができますか?」を決めることです。

  • 例:
    • ホテルにチェックインして部屋の鍵カードをもらう
    • アプリに写真へのアクセスを許可する

OAuth 2.0 は、この「認可」の仕組みを標準化したフレームワークです。

OAuth 2.0 の特徴

OAuth 2.0 は以下の特徴を持っています。

  1. セキュリティ
  • 直接的なパスワード共有が不要
  • トークンベースの安全なアクセス管理
  1. 多様なアプリケーション対応
  • Web アプリケーション
  • モバイルアプリケーション
  • デスクトップアプリケーションなど

詳細については、OAuth 2.0を参照してください。

OAuth 2.0 の動作フロー

Web アプリケーションが外部サービスのリソースにアクセスする際には、そのリソースへのアクセス権限を取得する必要があります。

外部サービスのリソースにアクセスする際、ユーザー ID とパスワードによる直接認証には以下のような問題があります。

  • セキュリティ上の問題

    • パスワードの漏洩リスク
    • アプリケーションがユーザーの認証情報を直接保持してしまう
  • 権限管理の問題

    • アプリケーションが全ての権限を取得してしまう
    • 必要最小限の権限制御が難しい
  • 運用上の問題

    • 外部サービスごとに認証情報の管理が必要
    • パスワード変更時の連携が複雑

OAuth 2.0 は、このような問題を解決し、安全にリソースへのアクセス権限を付与する方法を提供します。

OAuth 2.0 の基本ステップ

OAuth 2.0 の処理は、大きく分けて 2 つのステップで構成されています:

  1. アクセストークンの取得
  2. トークンの使用

実践編

今回作成するのは、GitHub のプロフィール写真を表示するシンプルなアプリケーションです。
OAuth2.0 認可フローの流れは以下のようになります:

  1. ユーザーが Web アプリケーションにアクセスし、ボタンをクリック
  2. アクセス権限がない場合、GitHub 認可ページへの遷移を確認
  3. アプリケーションサーバーは、GitHub の認可エンドポイントへの URL を生成
  4. ブラウザが GitHub の認可ページへリダイレクト
  5. ユーザーが GitHub にログイン(未ログインの場合)
  6. GitHub がアプリケーションの要求する権限スコープを表示
    • この場合:プロフィール写真へのアクセス権限(read:user)
  7. ユーザーが権限を承認すると、GitHub は認可コードを発行
  8. 事前に設定したコールバックURLへ認可コードと共にリダイレクト

認可フローのイメージは以下のようになります。

image

サンプルコードのディレクトリ構造は以下です。

├── app
│   ├── api
│   │   └── auth
│   │         └── callback
│   │               └── github
│   │                     └── route.js 
│   │   └── profile
│   │         └── route.js
│   ├── page.js
│   └── utils
│       └── createOrGetsession.js

メイン画面の実装

メイン画面の実装は以下のようになります。
シンプルなボタンを配置しています。

image

// /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」ボタンをクリックすると、以下のような流れで処理が進みます。

  1. まず、プロフィール画像を取得するためのエンドポイント(/api/profile)にリクエストが送られます。
  2. 初回アクセス時は session が存在しないため、401エラーが返されます。
  3. 401エラーを受けて、以下のような認可確認のポップアップが表示されます。

image

  1. 「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を参照してください。

image

上記の認可を実装すると 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 の認可フローが終わったら、以下の画面に戻ります。

image

「get profile image」ボタンをクリックすると、GitHub API を使用してユーザー情報を取得し、プロフィール画像が表示されます。

image

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 を奪われると、アクセストークンが奪われてしまいます。
https://github.com/login/oauth/authorize?client_id=Ovxxxxx&scope=read:user&redirect_uri=http://localhost:3000/api/auth/callback/github

※ 上記の 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

image

// /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