🛡️

OAuth + OIDCを「自分で扱う」意味とセキュリティリスク― Webアプリ開発者とIdPプロバイダ、それぞれの視点から

に公開

OAuth + OIDCを「自分で扱う」意味とセキュリティリスク

関連記事

― Webアプリ開発者とIdPプロバイダ、それぞれの視点から

前回の記事 OAuthとは何か?― 既存の認証方式と比較して理解する「OAuthが解いている問題」 では、OAuthとOIDCが何を解決する仕組みなのかを整理しました。
本記事では一歩踏み込んで、

  • Webアプリ開発者として、OAuth + OIDCをどう使うのか
  • IdPプロバイダとして、OAuth + OIDCをどう実装するのか
  • OAuth + OIDCの標準仕様がカバーしないセキュリティリスクは何か

を扱います。

実装のチェックリストや検証項目を読む前に、まず「OIDCフローが完了した時点でアプリがどんな状態になっているか」を確認しておきます。この土台を持っていないと、何を守るために何を実装しているのかが分かりにくくなります。


Part 1:Webアプリ開発者の視点

「OAuth + OIDCを使う」とは何をすることか

Webアプリ開発者が「OAuth + OIDCを使う」と言うとき、実際には2つの選択があります。

パターン 内容
A. 既存のIdPを利用する Google/GitHub/Microsoftなど外部のIdPと連携する 「Googleでログイン」
B. 自前のIdPを構築する 自社内にOIDC対応の認証サーバーを立てる Keycloak、Auth0などを使うか、自作する

多くのWebアプリはパターンAから始まります。
パターンBは「マイクロサービス構成での社内統一認証」や「BtoBのSSOサービス提供」が典型的なユースケースです。


パターンA:外部IdP連携(OAuth + OIDC)

ログイン完了時点でのアプリの状態

OIDCのコールバック処理が終わった時点で、アプリのサーバーは以下の情報を持ちます。

アプリが保持するもの 由来 アプリ側での扱い
sub(ユーザー外部ID) IDトークン users テーブルの外部キー
email / name IDトークン プロフィールの初期値
セッションID アプリが発行 セッションストア + HTTP-only Cookie
アクセストークン OAuthで取得 サーバーサイドのみ保持(外部API呼び出し用)

ここで重要な分離があります:

  • sub でユーザーをDBに登録・照合するのはOIDCの結果を使う部分
  • セッションIDはアプリが独自に発行するもの。以降のリクエスト認証はこのセッションIDで行う
  • アクセストークンはGoogle等の外部APIを呼ぶための鍵。ブラウザには渡さない

OIDCはセッション確立の「入口」であり、確立後はアプリ独自のセッション管理が担います。
IDトークンをそのままセッションとして使い回すのは誤りです(詳細はPart 2 セクション4)。

何を実装するか

外部IdP(例:Google)との連携では、開発者が実装するのは「OAuth認可コードフロー」のクライアント側です。

1. 認可リクエスト(Authorization Request)の生成
   □ state パラメータ(CSRF対策)を生成してセッションに保存
   □ nonce パラメータ(リプレイ攻撃対策)を生成してセッションに保存
   □ scope=openid を含める(OIDCを有効にする)
   □ PKCE用の code_verifier / code_challenge を生成する(SPAやモバイルの場合)
   □ ユーザーをIdPの認可エンドポイントへリダイレクト

2. コールバックエンドポイントの処理
   □ state をセッションと照合する(不一致なら処理中断)
   □ 認可コードをトークンエンドポイントへ送り、ID + アクセストークンを取得
   □ IDトークンの署名をIdPの公開鍵で検証する
   □ iss(発行者)が期待するIdPのURLと一致するか確認する
   □ aud(対象受信者)が自アプリのclient_idと一致するか確認する
   □ exp(有効期限)が切れていないか確認する
   □ nonce をセッションと照合する
   □ sub をキーにユーザーをDBに登録 or 照合する

3. セッション確立
   □ アクセストークンはサーバーサイドにのみ保存する(クライアントに露出しない)
   □ IDトークンをそのままセッション管理のトークンとして使わない
   □ アプリ独自のセッションIDを発行してブラウザにHTTP-only Cookieで返す

IDトークンは「認証の証明書」であり、APIアクセスのトークンではありません。
セッション管理には別途セッションID(CookieやJWTセッション)を発行してください。

なぜ自前でやる意味があるか

next-authpassport.js などのライブラリを使えば、ほぼ上記を自動化できます。
では自前で理解する意味は?

実際の開発現場で発生するのは「ライブラリが想定していないケース」です。

  • IdPから返ってくるクレームをどうDBスキーマに対応させるか
  • 複数のIdPを同一ユーザーに紐づける(アカウント連結)
  • テナント分離のあるBtoBアプリでの認証設計
  • 障害発生時にトークンや認可フローのどこで失敗したかを読む

フローを理解していないと、ライブラリが吸収している問題が「なぜ起きているのか」分からなくなります。


パターンB:自前IdPの構築

どういう場合に必要か

  • 社内で複数のWebアプリを持ち、SSOを実現したい
  • BtoBのSaaSで顧客企業ごとに認証プロバイダを分けたい
  • 認証フロー自体をカスタマイズしたい(MFA必須化など)

まずKeycloakやAuth0などのOSS・SaaSを検討する のが現実的です。それでも要件が合わない場合に自前実装を考えます。

OIDCにおけるDiscoveryの役割

クライアントがIdPと連携するには「どのURLに認可リクエストを送るのか」「トークンはどこで取得できるのか」「公開鍵はどこにあるのか」を事前に知る必要があります。

OIDCはこれを Discoveryドキュメント/.well-known/openid-configuration)で解決しています。クライアントはこのURLにアクセスするだけで、IdPとの接続に必要なすべての情報を自動取得できます。IdP実装者は、このドキュメントに記載した「約束」を守るエンドポイントを用意する義務があります。

自前IdPが持つべき機能

必須エンドポイント:

- 認可エンドポイント      GET  /authorize
    ユーザーをIdPのログイン画面へ誘導し、認証後に認可コードを発行する

- トークンエンドポイント  POST /token
    クライアントが認可コードを送り、IDトークン・アクセストークンを受け取る

- JWKSエンドポイント      GET  /.well-known/jwks.json
    IDトークンの署名に使った公開鍵を公開する(クライアントが署名検証に使う)

- Discoveryエンドポイント GET  /.well-known/openid-configuration
    上記エンドポイントのURLや対応仕様をまとめたメタデータを返す

発行物:
- アクセストークン(JWT or opaque)
- IDトークン(JWT、署名必須)
- リフレッシュトークン(オプション)

Discoveryドキュメントの例

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/token",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"]
}

クライアントはこのURLを見るだけで、どのエンドポイントにどう接続すればいいか分かります。
つまりIdP実装者は、このドキュメントの「約束」を守る義務があります。

IDトークンの署名

IDトークンは RS256(RSA + SHA-256) で署名するのが標準です(HS256は共有秘密鍵のため避ける)。

秘密鍵(IdPだけが持つ)→ IDトークンに署名
公開鍵(JWKSエンドポイントで公開)→ クライアントが署名を検証

鍵ローテーション(定期的に鍵ペアを更新)も考慮が必要です。JWTには kid(Key ID)を含め、クライアントが正しい公開鍵を選択できるようにします。

MFA(多要素認証)と AMR クレーム

自前のIdPでMFAを実装する場合、「ユーザーがどの認証方式を使ったか」をIDトークンに記録する標準的な方法があります。

AMR(Authentication Methods References)クレーム:

{
  "sub": "1234567890",
  "email": "tanaka@example.com",
  "amr": ["pwd", "otp"],
  "acr": "urn:mace:incommon:iap:silver",
  "exp": 1700000000
}
クレーム 意味 値の例
amr 使用した認証方式のリスト ["pwd"](パスワードのみ)、["pwd", "otp"](パスワード + ワンタイムパスワード)、["webauthn"](WebAuthn/パスキー)
acr 認証コンテキストクラス(認証強度の分類) IAP仕様またはサービス独自の文字列

主な amr 値:

認証方式
pwd パスワード認証
otp ワンタイムパスワード(TOTP/HOTP)
sms SMS認証
hwk ハードウェアキー(FIDO2等)
webauthn WebAuthn / パスキー

これらのクレームをIDトークンに含めることで、クライアントアプリは「このユーザーはMFAで認証されているか」を判断できます。利用シーンはPart 2「その他のセキュリティリスク」の「認証強度(AMR/ACR)の未検証」で説明します。


具体例:自作WebアプリにOIDCログインを組み込む

ここまでの内容を踏まえて、実際に自社でWebアプリを開発する場面でOIDCがどう機能するかを具体的に見ておきます。

社内ツール群(勤怠管理・経費申請)を自社開発していて、それぞれのアプリが個別にID/パスワード管理をしている状態を考えます。「一度ログインすればどのツールも使えるようにしたい(SSO)」という要件が出てきたとき、OIDC対応の認証サーバーを自前で立てることになります。

登場人物:

名称 役割 具体例
認証サーバー(IdP) 自作する認証専用アプリ auth.company.internal
勤怠管理アプリ IdPに認証を委譲するWebアプリ kintai.company.internal
経費申請アプリ 同上 expense.company.internal
ユーザー 社員 田中さん(tanaka@company.com

ログインの流れと情報の動き:

①ユーザーが勤怠管理アプリにアクセス
  [田中さんのブラウザ] → GET https://kintai.company.internal/dashboard
  → 未ログインなので、アプリがIdPへリダイレクト

②認可リクエスト(勤怠管理アプリ → IdP)
  [ブラウザ] → GET https://auth.company.internal/authorize
    ?client_id=kintai-app          ← 勤怠管理アプリの識別子
    &redirect_uri=https://kintai.company.internal/callback
    &response_type=code
    &scope=openid email profile
    &state=abc123                  ← CSRF対策トークン(アプリがセッションに保存)
    &nonce=xyz789                  ← リプレイ攻撃対策(同上)

③IdPがログイン画面を表示 → 田中さんがID/パスワードを入力
  [IdPのログイン画面でのみ認証情報を入力 ← 各アプリには渡らない]

④IdPが認可コードを発行してリダイレクト
  [IdP] → 302 Redirect to:
  https://kintai.company.internal/callback
    ?code=AUTH_CODE_XXXXXX         ← 短命(数分)の使い捨てコード
    &state=abc123                  ← ②で送ったstateが返ってくる

⑤アプリがコードをトークンに交換(サーバー間通信)
  [勤怠管理アプリのサーバー] → POST https://auth.company.internal/token
    grant_type=authorization_code
    code=AUTH_CODE_XXXXXX
    redirect_uri=https://kintai.company.internal/callback
    client_id=kintai-app
    client_secret=SECRET_YYYY      ← このアプリだけが知るシークレット

  [IdP → アプリのサーバーへ返却]
  {
    "id_token": "eyJhbGci...",     ← 田中さんの情報を含むJWT
    "access_token": "at_ZZZZ",    ← IdPのAPIを呼ぶための鍵
    "token_type": "Bearer",
    "expires_in": 3600
  }

⑥アプリがIDトークンを検証・ユーザー特定
  IDトークンをデコードすると:
  {
    "sub": "user-001",             ← 田中さんの内部ID(DBキー)
    "email": "tanaka@company.com",
    "name": "田中太郎",
    "amr": ["pwd"],
    "iss": "https://auth.company.internal",  ← 発行者検証
    "aud": "kintai-app",                     ← 自アプリ宛か確認
    "exp": 1700003600,                       ← 有効期限確認
    "nonce": "xyz789"                        ← ②で保存したnonceと照合
  }
  → users テーブルで sub をキーに田中さんのレコードを照合 or 初回なら作成

⑦アプリが自前のセッションを発行
  [アプリのサーバー]
  - セッションID(ランダム文字列)を生成してセッションストアに保存
  - access_token はセッションストアのみに保存(ブラウザには返さない)
  - ブラウザへ HTTP-only Cookie でセッションIDだけを返す

  Set-Cookie: session_id=SID_QQQQ; HttpOnly; Secure; SameSite=Lax

⑧以降のリクエストはセッションIDで認証(OIDCは関与しない)
  GET /dashboard → Cookie: session_id=SID_QQQQ → セッションストアで照合

経費申請アプリへのアクセス時(SSO):

田中さんがすでにIdPにログイン済みの状態で経費申請アプリにアクセスすると、

IdPは「このブラウザはセッションがある」と判断し、
②〜③のログイン画面をスキップして④の認可コード発行に直接進む

→ 田中さんは再度パスワードを入力せずにログインできる(SSO)

この流れを通して、各アプリが直接パスワードを扱わないという構造が実現できます。認証情報はIdPだけが保持し、各Webアプリは「誰がログインしたか」という結果だけを受け取ります。


Part 2:OAuth + OIDCがカバーしないセキュリティリスク

OAuth + OIDCは 認可と認証のフロー自体 を標準化しています。しかし「フローが正しく動いている」ことと「アプリ全体が安全である」ことは別の話です。

以下は、OAuth + OIDCを正しく実装したとしても 別途対応が必要なセキュリティリスク です。


1. OAuth CSRFによるアカウント乗っ取り

リスク

state パラメータの検証を省略すると、攻撃者が被害者のアカウントに任意の外部アカウントを連結できます。

具体的な攻撃シナリオ

前提:myapp.com がGitHub連携機能を持っている

1. 攻撃者が自分のGitHubアカウントで myapp.com の「GitHub連携」を開始
2. リダイレクト途中でブラウザを止め、コールバックURL(code=xxx&state=yyy)を保存
3. 被害者にそのURLをクリックさせる(メール/SNSなどで誘導)
4. myapp.com が state を検証していなければ、
   被害者のアカウントに攻撃者のGitHubアカウントが連結される
5. 次回「GitHubでログイン」を使えば、攻撃者が被害者のアカウントに侵入できる

対策

□ state パラメータを認可リクエスト時にランダム生成し、セッションに保存する
□ コールバック時に、受け取った state とセッションの state が一致するか検証する
□ 一致しない場合は処理を中断する

2. オープンリダイレクト攻撃

リスク

OAuth認可フローでは redirect_uri を使ってコールバック先URLを指定します。
IdPが redirect_uri を厳格に検証していないと、フィッシングサイトへ誘導できます。

具体的な攻撃シナリオ

正規のコールバックURL:
https://myapp.com/callback

攻撃者が細工したリクエスト:
https://idp.example.com/authorize?
  client_id=myapp
  &redirect_uri=https://evil.com/callback  ← 書き換え
  &response_type=code

IdP の redirect_uri 検証が前方一致だけであれば:
  https://myapp.com.evil.com/callback
  または
  https://myapp.com/callback/../../../evil.com

このURLへ認可コードが送られる
→ 攻撃者がコードを入手してトークンと交換できる

対策

IdPとして実装する場合:
  □ redirect_uri を登録制にし、完全一致(exact match)で検証する
  □ ワイルドカードや前方一致での検証は使わない

クライアントアプリとして実装する場合:
  □ redirect_uri にパラメータを動的に含めない
  □ 自アプリのドメイン外にリダイレクトする処理を作らない

3. 認可コードの横取り(Code Interception)

リスク

認可コードはブラウザのURLに乗って返ってきます(例:?code=4/aBcD...)。
これが盗まれると攻撃者がアクセストークンを入手できます。

具体的な攻撃シナリオ

モバイルアプリでのカスタムURIスキーム乗っ取り:

正規アプリ:com.myapp://callback
悪意あるアプリ(同じカスタムスキームを登録):com.myapp://callback

認可コードを含むコールバックURLを悪意あるアプリが横取り
→ トークンに交換して正規ユーザーとして動作

対策

□ PKCE(Proof Key for Code Exchange)を必ず使う
  - code_verifier(ランダム文字列)を生成する
  - その SHA-256 ハッシュ(code_challenge)を認可リクエストに含める
  - コードをトークンに交換するとき code_verifier を送る
  - IdP側で code_challenge と照合する
  → コードを横取りしても code_verifier がないとトークンに交換できない

4. セッション管理の不備

リスク

OAuth + OIDCでユーザーを認証した後の セッション管理はOAuthの仕様外 です。
IDトークンをそのままセッション管理に使うのは誤用であり、様々なリスクがあります。

具体的な問題

問題①:IDトークンをセッショントークンとして使う

❌ アンチパターン:
Cookie: id_token=eyJhbGci...  ← IDトークンをCookieに保存して認証トークン代わりにする

問題:
- IDトークンは有効期限が短い(通常1時間以下)
- クライアントへのIDトークン露出がないように設計されている
- セッション無効化(強制ログアウト)ができない

問題②:ログアウト後もトークンが有効

OIDCには id_token_hint を使ったログアウトフロー(RP-Initiated Logout)がありますが、
アクセストークンのリボーク(無効化)は実装しないと残り続けます。

対策

□ 認証後は自アプリのセッション(セッションID or 独自JWT)を発行する
□ IDトークンはユーザー特定にのみ使い、セッションとは分離する
□ アクセストークンはサーバーサイドのみで保持し、クライアントに返さない
□ ログアウト時はアクセストークンをリボークし、セッションを削除する
□ リフレッシュトークンはHTTP-only Cookieに保存する(JSから読めないように)

その他のセキュリティリスク

上記以外にも、実装時に意識すべきリスクがあります。

  • IDトークン検証の不備alg: none 攻撃(署名なしJWTの受け入れ)や aud 検証漏れ(別アプリ宛トークンの流用)。使用アルゴリズムをコード側でハードコードし、aud は必ず自アプリの client_id と照合する。

  • Implicit Flowの残存:URLフラグメントにトークンを返す旧方式で、ブラウザ履歴・Refererヘッダ経由でトークンが漏洩する。Authorization Code Flow + PKCEに移行し、トークンはlocalStorageに保存しない。

  • スコープの過剰付与repo admin:org など不要な権限まで要求すると、漏洩時の被害が拡大しフィッシングアプリとの区別もつかなくなる。必要最小限のスコープのみを要求する(最小権限の原則)。

  • 認証強度(AMR/ACR)の未検証:決済・パスワード変更などの高リスク操作で、パスワードのみのログインとMFAログインを区別していないと、MFA回避状態で操作が実行できてしまう。amr クレームを確認し、MFA未実施なら再認証へリダイレクトする(Step-up Authentication)。


参考資料

ヘッドウォータース

Discussion