🔥

OAuthの仕組みを説明してHonoで実装してみる

2024/07/16に公開
3

はじめに

はじめまして!レバテック開発部でレバテックプラットフォーム開発チームに所属している塚原です。

直近に認証・認可周りの改修を予定しているため、チーム内で認証・認可の基礎からOAuth・OpenID Connectの仕組みを学ぶ勉強会を実施しました。今回はそこで学んだことのうち、認証・認可の基礎とOAuthの仕組みをまとめます。また、WebフレームワークとしてHono、JavaScriptランタイムとしてBunを使って、OAuthクライアントを実装してみます。

対象読者

  • 認証と認可の違いってなんだっけ...?という人
  • Basic認証やDigest認証てなんだっけ...?という人
  • OAuthはライブラリ使って実装してるから仕組みよくわかっていない...という人
  • OAuthのクライアントの実装って何をすればいいんだっけ...?という人

認証・認可の基礎

2024/7/18 追記
こちらで記載している内容について、コメントにてご指摘を頂きました!ぜひ、合わせて読んでいただければと思います。

認証と認可の違い

認証とは「ユーザーが誰であるか」を確認するプロセスであり、英語でAuthenticationと表記します。
対して、認可とは「ユーザーが特定の権限を持っているか」を確認するプロセスであり、英語でAuthorizationと表記します。

例えば、
銀行口座を開設する時は、運転免許証などを提出して本人であることを確認すると思いますが、これは運転免許証を使って口座を開設する人が「誰であるか」を確認しているので認証(Authenticaion)にあたります。
対して、電車に乗車する際は改札に切符を通しますよね。これは、切符を使って電車に乗車する「権限を持っているか」を確認しているので認可(Authorization)にあたります。

エンジニアに身近な例でいえば、資格情報を使ってユーザーを特定するログイン機能は認証であり、管理ユーザーや一般ユーザーなどのロールによるアクセス制御は認可にあたります。

認証に基づく認可

実際のアプリケーションでは、認証のプロセスでユーザーが誰であるかを確認した上で、認可のプロセスでそのユーザーが特定の権限を持っているか確認する、というように認証の後に認可がセットで行われるケースが多いです。

例えば、
「ログイン機能で認証することでユーザーを特定し、そのユーザーに紐づくくロールを取得し、そのロールをもとに認可にすることで権限があるか確認する」といったケースです。実際のシステムでは、このケースのように認証のプロセスで特定したユーザーに対して、認可のプロセスで権限を確認する認証に基づく認可が行われることが多いです。

認証の3要素

認証情報(本人確認するために必要な情報)は大きく三つの要素に分けられます。

  • 知識情報(What you know)
    メールアドレス・パスワードなどの本人のみが知っている情報
  • 所有情報(What you have)
    運転免許証やパスポートなどの本人のみが持っている情報
  • 生体情報(What you are)
    指紋や顔などの本人の生体に備わっている固有の情報

上記のうちの1要素を用いて認証することを単要素認証、2要素以上を基いて認証することを多要素認証といいます。認証の段階を二段階に分ける二段階認証というものもありますが、必ずしも別の要素を組み合わせているわけではありません。

いろいろな認証方法

現在、Webアプリケーションで認証する方法は多岐にわたります。ここでは主要な認証方法について説明します。下記それぞれのフローでは、クライアントはブラウザを想定しています記載していますが、HTTPプロトコルを利用できるクライアントであればブラウザ以外にも適用できます。

Basic認証

HTTPが提供しているアクセス制御の仕組みを使った認証方式の一つでHTTP基本認証とも呼ばれます。

Basic認証のフロー

  1. 初回リクエスト
    ユーザーがアクセス制限のかかったリソースへアクセスすると、Basic認証を実装したサーバーはHTTP 401 (Unauthorized) を返却し、ブラウザに認証が必要なことを知らせます。レスポンスに含まれるWWW-Authenticateヘッダーは認証の種類がBasic認証であることを示しています。
  2. 認証情報を送信
    このレスポンスを受け取ったブラウザは、認証用のダイアログを表示します。そのダイアログにユーザーがユーザー名とパスワードを入力すると、Authorizationヘッダーにユーザー情報をセットして、サーバーにHTTPリクエストが送信されます。この時セットされるユーザー情報は、ユーザー名とパスワードをコロン(:)で結合しBase64エンコードした文字列になります。
  3. 認証処理
    2のHTTPリクエストを受け取ったサーバーは、Authorizationヘッダーにセットされたユーザー情報をBase64デコードして取得します。その上でサーバー側に保持しているユーザー情報と照合し、認証結果をブラウザに返却します。

通常ブラウザは一度Basic認証に成功したユーザー情報をキャッシュし、同様のリソースへのアクセス時に自動的にAuthorizationヘッダーに含めてサーバーに送信します。そのため、ユーザーはリクエストの度にユーザー情報を入力する必要はありません。

realmとは?

フロー図を見ると、サーバーからの認証要求のレスポンスでWWW-Authenticateヘッダーに realm(レルム) という値が設定されていることわかります。これは、認証が必要なリソースの範囲に名前を付けるためのもので、レルム毎に必要な認証情報を定義できます。
例えば、サーバー上で「リソースAとBはrealm="Realm1"」とし、「リソースCとDはrealm="Realm2"」というように設定されているとします。このとき、リソースAに初回アクセスすると、

WWW-Authenticate: Basic realm="Realm1"

というレスポンスが返却され、ユーザーはRealm1に紐づく認証情報を入力します。このとき、認証情報がブラウザにキャッシュされるため、リソースBへのアクセス時にユーザーが再度認証情報を求められない状態になります。しかし、リソースC, Dにアクセスした場合は、レルムが異なるため、

WWW-Authenticate: Basic realm="Realm2"

というレスポンスが返却され、ユーザーはRealm2に紐づく認証情報の入力を求められます。

Basic認証の特徴

  • 実装が容易
    通常であれば(ApacheやNginxなどの)Webサーバーの設定で容易にBasic認証でリソースへのアクセス制限がかけられます。
  • 低いセキュリティ
    ユーザー名とパスワードをBase64エンコードしますが、これは暗号化ではなく可逆的な変換なため、漏洩や改ざんのリスクがあります。そのためBasic認証を使用する場合はHTTPS/TLSでの暗号化が推奨されます。

Digest認証

Digest認証もHTTPが提供しているアクセス制御の仕組みを使った認証方式の一つです。
Basic認証ではユーザー情報を暗号化せずに送信する脆弱な仕組みになっていましたが、Digest認証ではユーザー情報を暗号化してサーバーに送信する仕組みになっており、Basic認証よりセキュアな認証方式になっています。

Digest認証のフロー

  1. 初回リクエスト
    ユーザーがアクセス制限のかかったリソースへアクセスすると、Digest認証を実装したサーバーはHTTP 401(Unauthorized) を返却し、ブラウザに認証が必要なことを知らせます、レスポンスに含まれるWWW-Authenticateヘッダーは認証の種類がDigest認証であることを示しています。
    また、この時Basic認証ではなかったnonceというサーバーで生成したランダムな文字列をWWW-Authenticateヘッダーに含めます。

  2. 認証情報を送信
    このレスポンスを受け取ったブラウザは、認証用のダイアログを表示します。そのダイアログにユーザーがユーザー名とパスワードを入力すると、Authorizationヘッダーに以下のパラメータをセットして、サーバーにHTTPリクエストが送信されます。

    パラメーター 説明
    username ユーザーが入力したユーザー名
    response ユーザー名とパスワードにnonce値を組み合わせて暗号化した文字列
    ※デフォルトではMD5という暗号化アルゴリズムが使用される。
    nonce サーバーから受け取ったされたランダム生成文字列
  3. 認証処理
    2のHTTPリクエストを受け取ったサーバーは、Authorizationヘッダーのusernameやnonceとサーバーに保存してあるパスワードを組み合わせて暗号化します。この時の暗号化は、クライアントでのresponse値を作成に使用した暗号化アルゴリズムが使われます。その暗号化した結果とAuthorizationヘッダーのresponse値を比較して、一致していれば認証成功とします。

Basic認証と同様に認証情報がキャッシュされるため、リソースアクセス毎にユーザー情報を入力する必要はありません。

Digest認証の特徴

  • 実装が容易
    Basic認証と同様、通常であれば(ApacheやNginxなどの)Webサーバーの設定で容易にDigest認証でリソースへのアクセス制限がかけられます。
  • Basic認証より高いセキュリティ
    パスワードを平文で送信する必要がない分、Basic認証より漏洩や改ざんのリスクは少ないです。
    しかし、暗号化の強度の問題などDigest認証にも脆弱な点が残るようなので、通常はよりセキュアな認証方式を用いた方が良さそうです。

セッションベース認証

セッションベース認証はWebアプリケーションでよく用いられる認証方式の一つです。
ブラウザからサーバーへのリクエストの度にユーザー情報を送信するBasic認証やDigest認証とは違い、セッションIDというユーザー情報と紐づけられたIDをリクエストに含めます。

セッションベース認証のフロー

以下フローでは、セッションIDの保存するセッションストアとしてデータベースを使っていますが、サーバー上のファイルやRedisなど他のセッションストアを使うこともあります。
セッションベース認証は「ユーザーがログインした後に、ログイン状態を保持する方法」であるため、ログインの方法は規定されていません。ただし下記フローではイメージを掴みやすくするためログインから記載しています。

  1. ログイン
    ユーザーがログインページにアクセスし、ユーザー名とパスワードなどのユーザー情報を入力します。サーバーにPOSTリクエストが送信され、サーバーは受け取ったユーザー情報をデータベースに問い合わせます。

  2. セッションIDを生成
    ブラウザから受け取ったユーザー情報とデータベース上に保存してあるユーザー情報が一致するすれば、認証が成功してサーバーでセッションIDを生成します。生成したセッションIDをデータベースに保存し、ブラウザにレスポンスを返却します。この時、生成したセッションIDをSet-Cookieヘッダーに設定します。ブラウザがログイン成功のレスポンスを受け取った後、ブラウザの内部データストアであるクッキーにセッションIDを保存します。

  3. リソースへのアクセス
    再度サーバー上のリソースにアクセスする時、Cookieヘッダーにクッキーに保存しているセッションIDが自動的に設定されます。リクエストを受け取ったサーバーは、セッションIDをキーにデータベースからユーザー情報を取得し、ユーザー情報の取得に成功すればリソースを返却します。

セッションベース認証では、一度セッションIDがブラウザのクッキーに保存されればクッキーが破棄されるまでは、再度ログインが必要になりません。

セッションベース認証の特徴

  • ステートフル
    セッションベース認証の場合、サーバーでセッションとユーザー情報の紐付きを管理する必要があります。つまり、セッションベース認証は、サーバー側で状態を保つ必要があるためステートフルな認証方式であると言えます。また、サーバー側に状態を持たせるためスケーラビリティに課題があります。

  • Basic/Digest認証より高いセキュリティ
    Basic認証やDigest認証では、リクエストの度にユーザー情報が送信されていました。セッションベース認証では、リクエスト毎にセッションIDのみ送信するため、よりセキュアであると言えると思います。
    セッションIDが盗まれた場合、他ユーザーのリソースにアクセスすることが可能になってしまうため、セッションの有効期限やクッキーのHttpOnly属性、Secure属性を使ってセッションIDが漏洩するリスクを低減することができます。

トークンベース認証

トークンベース認証も、セッションベース認証と同様Webアプリケーションでよく用いられる認証方式の一つです。
ユーザー情報と紐づけられたセッションIDを使って認証するセッションベース認証とは違い、アクセス可能なリソースの範囲(スコープ)の情報を含んだトークンを使って認証します。「ユーザーがログインした後に、ログイン状態を保持する方法」という点はセッションベース認証と同様です。

トークンベース認証のフロー

  1. ログイン
    ログインについては、セッションベース認証のフローに記載したフローと変わりません。

  2. トークンを発行
    サーバー上で、アクセス可能なリソースの範囲(スコープ)の情報を含んだトークンを発行します。トークンはセッションIDとは異なり、ユーザー情報を紐付けて管理をしません。通常は改ざんが難しいJWT形式のトークンを使用することが多いです。ブラウザがログイン成功のレスポンスを受け取った後、クッキーなどにトークンを保存します。

  3. リソースへのアクセス
    再度サーバー上のリソースにアクセスする時、 AuthorizationヘッダーBearer <トークン> の形式でトークンを設定し、リクエストを送信します。リクエストを受け取ったサーバーは、トークンが改ざんされていないことや有効期限内であることを検証し、リソースを返却します。

トークンベース認証では、ブラウザに保存されたトークンが破棄されるまでは、再度ログインが必要になりません。

トークンベース認証の特徴

  • ステートレス
    トークンベース認証では、セッションベース認証と違いサーバー上で状態を管理する必要がないため、ステートレスな認証方式であると言えます。そのため、スケーラビリティも高くなります。

  • トークンを無効化できない
    セッションベース認証の場合、セッションIDをサーバー側で保持しているため、認証状態を無効化できます。しかしトークンベース認証の場合、トークンをサーバー側で保持していないため、認証状態を無効化できません。ブラウザで保持しているトークンを破棄することはできますが、トークン自体が無効化されるわけではないため、例えばトークンが漏洩していた場合、漏洩したトークンを悪用して不正にアクセスされるリスクがあります。

トークン流出の影響緩和対策

上述の通りトークンは無効化できないため、流出した場合に不正利用されるリスクが高いです。不正利用のリスクを低減するための対策として、以下が考えられます。

  1. トークンの有効期限を短く設定する
    トークンの中に有効期限の情報を含めることができます。サーバー上でトークンを検証するとき、有効期限が切れているか確認し、切れていたら保護されたリソースへのアクセスを許可しません。
    この有効期限を短く設定することで、アクセストークンが流出した場合でも、不正利用される影響を緩和できます。ただし、有効期限を短く設定すればするほど、ユーザーがログインを求められる回数が増えるため、ユーザー体験が低下していきます。これを解決するために、リフレッシュトークンを活用できます。
    トークンの有効期限が切れた場合に、ブラウザからリフレッシュトークンを送信することで、新しいアクセストークンを発行することができます。トークンの有効期限を短く設定しても、リフレッシュトークンの有効期限を長くすることで、頻繁にログインが求められないようにしてユーザー体験の低下を防ぐことができます。

  2. トークンのブラックリストを管理
    サーバー側でトークンのブラックリストを管理する方法です。無効化したいトークンをブラックリストに追加し、ブラックリストに載っているトークンを含むリクエストは、保護されたリソースへのアクセスを許可しないようにします。
    ただし、サーバー側で状態を保つ必要がないステートレスな認証方式であるというトークンベース認証のメリットが損なわれ、スケーラビリティが低下してしまいます。

OAuth

OAuthについては雰囲気で OAuth2.0 を使っているエンジニアが OAuth2.0 を整理して、手を動かしながら学べる本をベースに学習を進めました。以下では、OAuthの仕組みの基本的な内容に絞って説明をしていますので、より詳細の情報を知りたくなった方は、ぜひこの本をご参照ください。

OAuthとは?

OAuth2.0は、RFC 6749で以下のように定義されています。

OAuth 2.0 は, サードパーティーアプリケーションによるHTTPサービスへの限定的なアクセスを可能にする認可フレームワークである.

大事なのは認可のフレームワークであり、認証のフレームワークではないという点です。認可のためのフレームワークであるにも関わらずOAuthを認証に利用するOAuth認証と呼ばれるものもありますが、脆弱性のある実装になる可能性が高いため推奨されていません。 (本記事では、OAuth認証については説明しません。)

定義を見てもイメージが掴みづらいと思うので、簡単な例で説明します。
例えば、「Google Drive内に保存したファイルを一覧できるアプリケーション」を作成したいとします。(以下、「ドライブ一覧アプリ」と記載します。)このアプリは、Google Drive上にあるファイルを取得する権限を持つ必要があり、この権限を得るための仕組みがOAuthになります。権限を得るための仕組みなので、認可のためのフレームワークであるというわけです。

OAuthの登場人物

OAuthには以下4つのロールが定義されいます。

  • リソースオーナー
    保護されたリソースの所有者のことです。ドライブ一覧アプリの例では、Google Drive上のファイルが「リソース」に該当するため、Google Driveのユーザーがリソースオーナーということになります。

  • リソースサーバー
    保護されたリソースを提供するサーバーのことです。ドライブ一覧アプリの例では、Google Driveのファイルを取得するGoogle Drive APIを提供するGoogleのサーバーということになります。

  • クライアント
    保護されたリソースにアクセスするアプリケーションのことです。ドライブ一覧アプリ自体を指します。

  • 認可サーバー
    保護されたリソースへのアクセス権限をクライアントに与えるサーバーのことです。ドライブ一覧アプリの例では、ファイル所有者(リソースオーナー)の同意をもって、GoogleのOAuthサーバー(認可サーバー)がクライアントにアクセス権限を与えます。認可サーバーは、アクセストークンと呼ばれるトークンをクライアントに発行することで、保護されたリソースへのアクセス権限を与えています。

OAuthのフロー

「保護されたリソースへのアクセス権限を与える(=アクセストークンを発行する)方法」のことをOAuthではグラントタイプと言い、基本仕様では以下4つのグラントタイプを定義しています。

  • 認可コードグラント
  • インプリシットグラント
  • クライアントクレデンシャルグラント
  • リソースオーナーパスワードクレデンシャルグラント

これらのグラントタイプごとアクセストークンを発行するフローが異なっています。今回は最も重要な「認可コードグラント」のフローを説明していきます。

認可コードの取得

  1. OAuth開始
    リソースオーナーがOAuth開始操作を実施するところからOAuthのフローが開始します。ドライブ一覧アプリの例では、「ファイル取得」ボタンを押下することによりOAuthが開始します。
    クライアントはOAuth開始のリクエストを受け付けると、HTTP 302を返却し、認可サーバーが提供する認可エンドポイントへリダイレクトさせます。このリクエストを認可リクエストと呼びます。ドライブ一覧アプリの例では、リダイレクトによってブラウザから認可エンドポイントに下記のリクエストを送信します。

    GET /o/oauth2/v2/auth
        ?response_type=code
        &scope=https://www.googleapis.com/auth/drive.readonly
        &client_id=YOUR_CLIENT_ID
        &redirect_uri=YOUR_REDIRECT_URL
    
    HTTP/1.1
    Host: accounts.google.com
    

    /o/oauth2/v2/authはGoogleの認可サーバー上の認可エンドポイントのURLで、以下の4つのクエリパラメーターを付与してリクエストが送信されます。

    パラメーター 説明
    response_type 「code」と設定します。認可サーバーは、これにより認可コードの発行を求められていることを知ります。
    scope クライアントが必要な権限の範囲を指定します。https://www.googleapis.com/auth/drive.readonlyは、 Google Driveの読み取り専用のアクセスを提供するスコープです。
    client_id リソースの提供元(Googleなど)に事前に登録しておくクライアントの識別子です。登録方法は、「OAuthクライアントの実装例」にて後述します。
    redirect_uri リソースの提供元に事前に登録しておくリダイレクトエンドポイントのURLです。リダイレクトエンドポイントについては、「権限委譲の同意」のフローで後述します。
  2. ユーザー認証
    認可サーバーはリソースオーナーに認証を要求します。ドライブ一覧アプリの場合、Googleログインが求められます。すでにブラウザに認証情報がキャッシュされている場合は、このステップはスキップされます。ユーザー認証のやり取りはブラウザと認可サーバーの間で行わるため、クライアントがGoogleの認証情報を知ることはありません。

  3. 権限委譲の同意
    認可サーバーは権限をクライアントに委譲して良いかをリソースオーナーに確認します。リソースオーナーが権限委譲の確認画面で同意することで、ブラウザに認可レスポンスを返却します。認可レスポンスは以下のようになっています。

    HTTP/1.1 302 Found
    Location: YOUR_REDIRECT_URI?code=YOUR_AUTH_CODE
    

    HTTP 302でブラウザからリダイレクトエンドポイント(YOUR_REDIRECT_URI) にリダイレクトさせています。リダイレクト時にcode=YOUR_AUTH_CODEをクエリパラメーターとして設定していますが、これは認可コードという「リソースオーナーが権限委譲に同意した証」になります。この認可コードを使って後続のフローでアクセストークンを取得することになります。
    また、リダイレクトエンドポイントは事前にリソースの提供元(Googleなど)に事前に登録しておき、クライアントでの実装が必要になります。

アクセストークンの取得

リダイレクトエンドポイントは、認可サーバー上のトークンエンドポイントにアクセストークンを取得するためのリクエストを送信します。このリクエストはトークンリクエストと呼び、以下のようになっています。

POST /oauth2/v4/token HTTP/1.1
Host: www.googleapis.com
Authorization: Basic BASE64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
    &code=YOUR_AUTH_CODE
    &redirect_uri=YOUR_REDIRECT_URI

それぞれのパラメータについて説明します。

パラメーター 説明
Authorization 認可サーバーでクライアントを認証するための値をBasic認証の仕組みを使って送信します。BASE64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)の部分は、事前にリソースの提供元に登録した「クライアントID」と「クライアントシークレット」を:で繋いでBase64エンコードした値が設定されます。登録方法は、「OAuthクライアントの実装例」にて後述します。
grant_type 認可サーバーにグラントタイプを伝えるための値です。認可コードグラントの場合は「authorization_code」を設定します。
code ブラウザから受け取った認可コードを設定します。
redirect_uri リダイレクトURIを設定します。

トークンリクエストを受け取ったトークンエンドポイントは、認可コードに紐づくスコープに紐づくアクセストークン生成し、トークンレスポンスを返却します。トークンレスポンスは、以下のような形式になっています。

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
    "access_token":YOUR_ACCESS_TOKEN,
    "token_type":"Bearer",
    "expires_in":3600,
    "refresh_token":YOUR_REFRESH_TOKEN,
}

access_tokenに入っている文字列がアクセストークンです。アクセストークンには、スコープと有効期限が紐づいており、これらの情報を元にリソースへのアクセス可否を判断します。

refresh_tokenとは?

トークンレスポンスに含まれているrefresh_token(リフレッシュトークン) は、「クライアントから認可サーバーに対してアクセストークンの再発行を要求するためのトークン」です。
通常、アクセストークンは流出した場合の影響緩和のため有効期限が短く設定されますが、アクセストークンの有効期限が切れる度に権限委譲への同意が必要になりユーザー体験が低下してしまいます。
そのため、アクセストークンの有効期限が切れたときに、長期間の有効期限をもつリフレッシュトークンを使ってアクセストークンを再発行する仕組みをクライアントに導入することで、ユーザー体験を低下せずにセキュリティを担保できます。

Googleの認可サーバーへのアクセストークン再発行のリクエストは以下のようになります。

POST /oauth2/v4/token HTTP/1.1
Host: www.googleapis.com
Authorization: Basic BASE64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
    &refresh_token=YOUR_REFRESH_TOKEN

このリクエストでは、リフレッシュトークンに加え、クライアントIDとクライアントシークレットを送信する必要があります。もし、リフレッシュトークンが流出しても、クライアントIDとクライアントシークレットが流出しなければ、不正にアクセストークンを再発行することができないため、セキュリティリスクが低くなっています。

リソースへのアクセス

クライアントからリソースサーバーにリソースの取得をリクエストします。リソースへのリクエストは以下のようになっています。

GET /drive/v3/files HTTP/1.1
Host: /www.googleapis.com
Authorization: Bearer YOUR_ACCESS_TOKEN

YOUR_ACCESS_TOKENの部分にはアクセストークンを指定します。このリクエストを受け取ったリソースサーバーは、アクセストークンを検証し、有効期間が切れていないこと、要求されているリソースがスコープ範囲内であることを確認します。検証した結果、問題なければクライアントにリソースを返却し、最終的にブラウザに表示されます。

Hono✖️BunでのOAuthクライアントの実装例

OAuthクライアントの実装例として、説明に使ってきた「ドライブ一覧アプリ」を認可コードグラントで実装していきます。どんな言語やフレームワークでも実装は可能ですが、今回は話題のWebフレームワークであるHonoとJavaScriptランタイムであるBunを組み合わせて実装していきます。

https://hono.dev/
https://bun.sh/

OAuthクライアントの実装例を示すためのかなり簡易的なアプリですが、完成形は下記のような表示になります。「ファイル取得」ボタンを押下することで、アプリのユーザー(リソースオーナー)のGoogle Drive上に保存されているファイルが一覧化できます。

順を追ってアプリを実装する手順を説明していきます。

クライアントの登録

OAuthを使ってGoogle Drive APIを使用するにあたり、いくつか事前の設定が必要になります。

  1. Google Cloud の利用規約の同意
    まず、Google Cloudの利用規約に同意する必要があります。Google Cloudコンソールにアクセスして、利用規約に同意してください。

  2. プロジェクトの作成
    次にGoogle Cloudプロジェクトを作成します。利用規約同意後の画面で「Select Project > NEW PROJECT」と辿っていくと下記の新規プロジェクト作成画面が表示されます。この画面でプロジェクト名を入力してプロジェクトを作成します。

  3. Google Drive API の有効化
    次に作成したプロジェクトを開き、検索窓から「Google Drive API」を探してAPIを有効化します。

  4. 認証情報の作成
    最後に認証情報を作成していきます。このステップで、OAuthに必要なスコープやリダイレクトURIを登録したり、認可サーバーがクライアントを認証するために必要な「クライアントID」や「クライアントシークレット」を取得します。
    Google Drive APIの詳細画面で「CREATE CREDENTIALS」ボタンを押下し、認証情報の作成画面を開き、下記のように必要情報を入力していきます。
    ※今回作成するアプリでは、スコープはGoogle Driveの読み取り専用アクセスであるhttps://www.googleapis.com/auth/drive.readonlyで、リダイレクトURIはhttp://127.0.0.1:3333/callbackを設定しています。





    認証情報の作成が完了した後に「DOWNLOAD」ボタンから「クライアントID」や「クライアントシークレット」の情報をダウンロードしておいてください。

  5. テストユーザーの登録
    本来、GoogleのOAuthを利用するためには、クライアントのアプリを審査に通す必要があります。ただし、今回は公開するアプリではないため審査に出しません。この場合でもテストユーザーを登録しておくことで、そのユーザーのみOAuthによって権限を委譲することができます。
    プロジェクトの「OAuth consent screen」でテストユーザーの追加画面を開き、テストで利用するGoogle アカウントを登録します。

クライアントの実装

今回OAuthクライアントを実装は、Hono✖️Bunを使うため、前提として動作環境のセットアップが必要になります。下記のHonoの公式ドキュメントの手順の通りに簡単にセットアップできます。
https://hono.dev/docs/getting-started/bun

動作環境のセットアップができれば、いよいよOAuthクライアントの実装を見ていきます。
いきなりですが、下記が実装内容の全てです。LayoutContentは、アプリのUIの実装でhono/jsxを使っています。

index.tsx
import axios from 'axios';
import { Hono } from 'hono';

const app = new Hono()

const Layout = (props: { children?: any }) => (
  <html>
    <body>{props.children}</body>
  </html>
)

const Content = (props: {fileNames?: string[]}) => {
  const items = props.fileNames && 
    props.fileNames.map((fileName) => (<li>{fileName}</li>))

  return (
    <Layout>
      <h1>Welcome to Authorization App</h1>
      <div>Google Driveのファイルを取得して表示します。</div>
      <button onclick="window.location = '/list'">ファイル取得</button>

      <ul>{items}</ul>
    </Layout>
  )
}

// 「クライアントの登録」で取得した値を変数に設定する
const CLIENT_ID = "××××××××××××××××××××";
const CLIENT_SECRET = "××××××××××××××××××××";
const REDIRECT_URI = "http://127.0.0.1:3333/callback";
const SCOPE = "https://www.googleapis.com/auth/drive.readonly";

app.get('/', (c) => {
  return c.html(<Content/>)
})

app.get('/list', (c) => {
    // ブラウザから認可サーバーの「認可エンドポイント」に
    // 「認可リクエスト」を送信するようにリダイレクトする
    const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
    const params = {
        client_id: CLIENT_ID,
        response_type: "code",
        scope: SCOPE,
        redirect_uri: REDIRECT_URI
    };
    return c.redirect(`${AUTH_ENDPOINT}?${new URLSearchParams(params)}`);
});

app.get('/callback', async (c) => {
    // アクセストークンの取得
    // 認可サーバーに「トークンリクエスト」を送信して、アクセストークンを取得する
    const TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v4/token";
    const params = {
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: "authorization_code",
        code: c.req.query('code') || '',
        redirect_uri: REDIRECT_URI
    }
    const { data: { access_token } } = await axios.post(TOKEN_ENDPOINT, new URLSearchParams(params));

    // リソースへのアクセス
    // アクセストークンを使って、リソースサーバーからドライブ内のファイルを取得して、ブラウザに返却する
    const DRIVE_API_ENDPOINT = "https://www.googleapis.com/drive/v3/files";
    const { data: { files } } = await axios.get(DRIVE_API_ENDPOINT,
        {
            headers: {
                Authorization: `Bearer ${access_token}`
            }
        }
    );
    const fileNames = files.map((file: { name: string; }) => file.name);
    return c.html(<Content fileNames={fileNames}/>)
});

export default { 
  port: 3333, 
  fetch: app.fetch, 
}

以下では、OAuthクライアントとして実装が必要な

  • OAuthの開始リクエストを受け付け、ブラウザを認可エンドポイントにリダイレクトさせるエンドポイント
  • リダイレクトエンドポイント

の二つについて実装を説明していきます。 (下記フロー図の赤枠部分)

まずは前者の実装です。
「ファイル取得」ボタンを押下した時に/listにリクエストが送信されるようにしています。認可リクエストに必要なパラメーターをクエリパラメーターにセットして、ブラウザから認可エンドポイントへリダイレクトさせています、(client_idは、クライアント登録時に取得したクライアントIDを利用してください。)

app.get('/list', (c) => {
    // ブラウザから認可サーバーの「認可エンドポイント」に
    // 「認可リクエスト」を送信するようにリダイレクトする
    const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
    const params = {
        client_id: CLIENT_ID,
        response_type: "code",
        scope: SCOPE,
        redirect_uri: REDIRECT_URI
    };
    return c.redirect(`${AUTH_ENDPOINT}?${new URLSearchParams(params)}`);
});

次に後者のリダイレクトエンドポイントの実装です。
クライアントの登録時にリダイレクトURIをhttp://127.0.0.1:3333/callbackで登録したため、/callbackでリクエストを受け付けます。必要なパラメータをセットしてトークンリクエストを送信することで、Googleの認可サーバーからアクセストークンを取得しています。
取得したアクセストークンを使って、Google Drive APIを提供しているリソースサーバーからファイルのデータを取得しています。最後に取得したデータからファイル名のみを取り出してブラウザに表示させています。

app.get('/callback', async (c) => {
    // アクセストークンの取得
    // 認可サーバーに「トークンリクエスト」を送信して、アクセストークンを取得する
    const TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v4/token";
    const params = {
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: "authorization_code",
        code: c.req.query('code') || '',
        redirect_uri: REDIRECT_URI
    }
    const { data: { access_token } } = await axios.post(TOKEN_ENDPOINT, new URLSearchParams(params));

    // リソースへのアクセス
    // アクセストークンを使って、リソースサーバーからドライブ内のファイルを取得して、ブラウザに返却する
    const DRIVE_API_ENDPOINT = "https://www.googleapis.com/drive/v3/files";
    const { data: { files } } = await axios.get(DRIVE_API_ENDPOINT,
        {
            headers: {
                Authorization: `Bearer ${access_token}`
            }
        }
    );
    const fileNames = files.map((file: { name: string; }) => file.name);
    return c.html(<Content fileNames={fileNames}/>)
});

動作確認

作成したドライブ一覧アプリの動作を見ていきます。まず、bun run devコマンドでサーバーを起動し、http://localhost:3333にアクセスすると、下記の画面が表示されます。

「ファイル取得」ボタンを押下すると、Googleへのログインを求められます。

Googleへのログインが完了すると、権限委譲の確認画面が表示されます。今回は、Google Driveへの読み取り専用の権限をクライアントアプリに委譲して良いかを確認されています。

この権限委譲に同意することで、下記のようにGoogle Drive内のファイルが取得できます。

おわりに

今回の認証・認可周りの勉強会を実施して、認証や認可はほとんどのWebアプリケーションで必須の実装でありながらも、正しい理解を持って実装しなければ脆弱性に繋がってしまうと改めて感じさせられました。
OAuthについても紹介しきれていない内容がまだまだあるので、ぜひ雰囲気で OAuth2.0 を使っているエンジニアが OAuth2.0 を整理して、手を動かしながら学べる本で理解を深めていただけると良いと思います!

レバテック開発部

Discussion

kyo8300kyo8300

文章が読みやすくて、内容も綺麗にまとめられていたので大変勉強になりました。
ありがとうございます。
これからの投稿楽しみにしてます!

ritouritou

OAuthが出てくるまでの流れで気になった点があったのでコメントさせていただきます。

銀行口座を開設する時は、運転免許証などを提出して本人であることを確認すると思いますが、これは運転免許証を使って口座を開設する人が「誰であるか」を確認しているので認証(Authenticaion)にあたります。

このような確認は「当人認証(Authentication)」ではなく「身元確認(Identity Proofing)」として扱われています。
「事前に提示していた身分証を後から再度提示して...」みたいな場合は当人認証になりますが、当人認証の例を出すのであれば「ATMでキャッシュカードと暗証番号を入れて...」みたいな文脈の方が適切です。

認証に基づく認可
認証の3要素

と良い流れで説明いただいたところですが、

いろいろな認証方法

特にセッションベース、トークンベースの何がしを認証の例として説明することに違和感があります。
認証の方法として挙げるとしたら適切なのは前述の要素に対応したもの(パスワード認証、SMS/Email OTP、認証アプリ、パスキー認証)のあたりが一般的です。

実際のアプリケーションでは、認証のプロセスでユーザーが誰であるかを確認した上で、認可のプロセスでそのユーザーが特定の権限を持っているか確認する、というように認証の後に認可がセットで行われるケースが多いです。

セットで行われるケースは多いですが、この投稿のOAuthの前の部分までは「認証」と言う言葉に「認可」も含んでいるように見えます。
しかし、認可処理もいくつかやり方はあります。

  • ユーザーIDを何らかの方法でサービスに提示し、そこから属性などを使ってアクセスコントロールの判断を行う
  • このリソースにアクセスしていいよという情報を提示し、アクセスコントロールの判断を行う

その観点で行くと、ここで紹介されている方法は

  • Basic/Digest認証 : ユーザーのクレデンシャルを直接指定することで認証しつつ、そのユーザーの権限なりを確認してアクセスコントロールを行う
  • セッション/トークンベース : セッションIDやトークンからユーザーが誰かの+αと言う情報を確認してアクセスコントロールを行う

のように整理できるはずです。
そしてセッションベースとトークンベースと表現しているところですが、Cookieなりトークンなりの「管理方法」と「データフォーマット」と言う整理をすると理解が深まるでしょう。
セッションベースと言っているのはHTTP Cookieの仕組みとセッションID(つまり識別子型)の組み合わせ、トークンベースの方はlocalStorageやネイティブアプリのストレージとJWTなどの内包型トークンの組み合わせが一般的である、と言う整理の方が理解しやすくなるでしょう。

そして、この整理でいくとこの後のOAuthへの繋ぎもスムーズになります。

  • ここまではブラウザがクライアントとなりリソースアクセスをしていたが、SPAやネイティブアプリだったり3rd partyのアプリケーションの場合もある
  • 特に3rd partyアプリケーションがBasic認証、Digest認証などを行うとすると漏洩や悪用というリスクだったりMFAが難しいという課題がある
  • 3rd partyアプリケーション自体にユーザー認証をさせずにアクセストークンを発行することで安全にアクセスコントロールを実現するのがOAuth

参考にしてみてください。

かにかに

ご丁寧にコメントいただきありがとうございます!わかりやすく記載いただき、とても勉強になります....!まだまだ理解が足りない部分もあるかもしれませんが、コメントを受けWeb上の情報なども参考しつつ、自分なりに理解した内容も記載させていただきます!

「身元確認(Identity Proofing)」と「当人認証(Authentication)」について

「身元確認(Identity Proofing)」と「当人認証(Authentication)」の違いを意識できておりませんでした...
以下のように理解しました。

  • 身元確認(Identity Proofing)
    本人確認書類を確認することで、サービスの利用者が実在する本人であるかを確認すること。
    銀行口座開設の例は、本人確認書類として運転免許証などを提示し、「申請者が実在するか」や「提示された情報が正しいか (偽装されていないかなど)」を確認するため、身元確認であると言える。

  • 当人認証(Authentication)
    あらかじめ登録されている情報と利用者が提示する情報を照合し、当人であるかを確認すること。
    ATMでの引き出しの例は、キャッシュカードとあらかじめ登録された暗唱番号を照合し、当人 (=キャッシュカードの持ち主)であること確認するため、当人認証であると言える。
    ※この「あらかじめ登録されている情報」の種類に3要素 (知識情報、所有情報、生体情報)がある。

特にセッションベース、トークンベースの何がしを認証の例として説明することに違和感があります。
認証の方法として挙げるとしたら適切なのは前述の要素に対応したもの(パスワード認証、SMS/Email OTP、認証アプリ、パスキー認証)のあたりが一般的です。

ありがとうございます!
自分自身、「Basic/Digest認証」と「セッション/トークンベース」を認証方法として記載することに多少の違和感はありながらも、言語化できておりませんでした。。。

Basic/Digest認証 : ユーザーのクレデンシャルを直接指定することで認証しつつ、そのユーザーの権限なりを確認してアクセスコントロールを行う
セッション/トークンベース : セッションIDやトークンからユーザーが誰かの+αと言う情報を確認してアクセスコントロールを行う

ありがとうございます!自分なりに下記のように理解しました...!
Basic/Digest認証は、リクエストの度に認証し、認証した結果を元にして認可を行う。
セッション/トークンベースについては、ログイン時に認証し、そのログイン状態を維持した上で、セッションIDやトークンから取得した情報を元に認可を行う。

そしてセッションベースとトークンベースと表現しているところですが、Cookieなりトークンなりの「管理方法」と「データフォーマット」と言う整理をすると理解が深まるでしょう。
セッションベースと言っているのはHTTP Cookieの仕組みとセッションID(つまり識別子型)の組み合わせ、トークンベースの方はlocalStorageやネイティブアプリのストレージとJWTなどの内包型トークンの組み合わせが一般的である、と言う整理の方が理解しやすくなるでしょう。

ありがとうございます!観点分けて整理することで理解しやすくなったと思います!

ここまではブラウザがクライアントとなりリソースアクセスをしていたが、SPAやネイティブアプリだったり3rd partyのアプリケーションの場合もある
特に3rd partyアプリケーションがBasic認証、Digest認証などを行うとすると漏洩や悪用というリスクだったりMFAが難しいという課題がある
3rd partyアプリケーション自体にユーザー認証をさせずにアクセストークンを発行することで安全にアクセスコントロールを実現するのがOAuth

ありがとうございます!
確かにこのように整理いただくことで、OAuthクライアント (3rd partyアプリケーション) に認証情報を持たせずにアクセストークンを発行することで、何が嬉しいのか、理解しやすいように感じました。