OAuth 2.0とOpenID Connectの仕組み:認可と認証の基礎

OAuth 2.0の認可フローとOpenID Connectの認証レイヤーを図解とともに解説し、アクセストークン・IDトークンの仕組みとセキュリティ上の注意点を紹介します。

はじめに

Webアプリケーションにおいて、ユーザーの認証(Authentication: 本人確認)と認可(Authorization: 権限付与)は最も重要なセキュリティ機能です。

  • 認証: 「あなたは誰ですか?」→ ユーザーの身元を確認する
  • 認可: 「あなたは何ができますか?」→ リソースへのアクセス権限を制御する

OAuth 2.0は認可のためのフレームワーク、**OpenID Connect(OIDC)**はOAuth 2.0の上に構築された認証レイヤーです。本記事では、それぞれの仕組みとセキュリティ上の注意点を解説します。

OAuth 2.0の概要

4つの役割

OAuth 2.0では4つの役割(ロール)が定義されています。

役割説明
Resource Ownerリソースの所有者エンドユーザー
ClientリソースにアクセスするアプリWebアプリ、モバイルアプリ
Authorization Serverトークンを発行するサーバーGoogle、Auth0
Resource Server保護されたリソースを提供API サーバー

クライアント登録

OAuthを利用するには、事前にAuthorization Serverにクライアントを登録し、以下の情報を取得します。

  • client_id: クライアントの識別子(公開情報)
  • client_secret: クライアントの秘密鍵(機密、サーバーサイドのみ)
  • redirect_uri: 認可後のリダイレクト先URL

OAuth 2.0のグラントタイプ

Authorization Code Grant(認可コードグラント)

Webアプリケーションで最も一般的なフローです。

1. Client → Authorization Server: 認可リクエスト
   GET /authorize?response_type=code
     &client_id=CLIENT_ID
     &redirect_uri=REDIRECT_URI
     &scope=read write
     &state=RANDOM_STATE

2. Resource Owner: ログイン&同意

3. Authorization Server → Client: 認可コード返却
   302 Redirect to REDIRECT_URI?code=AUTH_CODE&state=RANDOM_STATE

4. Client → Authorization Server: トークン交換(バックチャネル)
   POST /token
     grant_type=authorization_code
     &code=AUTH_CODE
     &redirect_uri=REDIRECT_URI
     &client_id=CLIENT_ID
     &client_secret=CLIENT_SECRET

5. Authorization Server → Client: アクセストークン返却
   { "access_token": "...", "token_type": "bearer", "expires_in": 3600 }

なぜこの5ステップなのか:フロントチャネルとバックチャネルの分離という設計思想

認可コードフローが「認可コード」と「アクセストークン」という2種類のトークンを経由する2段構えになっているのは、偶然の設計ではありません。ステップ1〜3はフロントチャネル(ブラウザのリダイレクトを経由する経路)を通り、ステップ4〜5はバックチャネル(クライアントとAuthorization Server間の直接的なHTTPS通信で、ブラウザを経由しない)を通ります。この2チャネル分離こそがOAuth 2.0のセキュリティモデルの核です。

フロントチャネル(ブラウザのURL)は、原理的に以下の経路から漏洩し得ます。

  • ブラウザの閲覧履歴: URLはブラウザに記録され、共有端末や履歴同期サービス経由で第三者に見られる可能性がある
  • Referrerヘッダ: リダイレクト先のページが外部リソース(画像・スクリプト・広告タグ等)を読み込むと、現在のURL全体が Referer ヘッダとして送信されてしまうことがある
  • サーバーのアクセスログ: Webサーバーは通常リクエストURL全体(クエリパラメータ含む)をアクセスログに記録するため、ログの閲覧権限を持つ者に漏れる
  • 中間プロキシやCDNのログ

そのためOAuth 2.0の設計者は、フロントチャネルを通過する値には**「単独では価値を持たない、短命で単回使用の値」**だけを流す方針を取りました。これが認可コードです。認可コードには次の制約が課されます(RFC 6749 §4.1.2)。

  • 有効期限が極めて短い(推奨は最大10分程度、実装によっては数十秒〜1分)
  • 単回使用(一度トークン交換に使われたら、Authorization Serverはそのコードを即座に無効化しなければならない。同じコードで2回目のトークン交換が試みられた場合、実装によっては関連する既発行トークンもすべて失効させることが推奨される — コード漏洩が発覚した際の被害を最小化するため)
  • 発行時の client_id / redirect_uri と、交換時に提示された値が一致することを要求(他のクライアントへの流用を防ぐ)

一方、実際に長期間有効でAPIアクセス権限そのものを表すアクセストークンは、バックチャネル(TLSで保護されたサーバー間通信)でのみやり取りされ、ブラウザのURLやログに一切現れません。つまり「フロントチャネルには価値の低い引換券(認可コード)だけを流し、価値の高い実物(アクセストークン)はバックチャネルの中だけで受け渡す」というチャネル分離によるリスク局所化が、このフローの設計原理です。

この観点に立つと、後述するImplicit Grant(暗黙的フロー)がなぜ非推奨になったかが論理的に説明できます。Implicit Grantはバックチャネル通信を省略し、アクセストークンを直接フロントチャネル(URLフラグメント)で返却する設計でした。しかしこれは前述のフロントチャネル漏洩経路(履歴・Referrer・ログ)に、本来最も保護すべきアクセストークンそのものを晒すことを意味します。URLフラグメント(#以降)はサーバーに送信されないためReferrerやサーバーログには残りにくいものの、ブラウザ履歴やブラウザ拡張機能、同一ページ内の悪意あるJavaScript(window.location.hashを読み取るXSS)には無防備であり、さらにリフレッシュトークンを発行できない(バックチャネルがないため)という制約もありました。OAuth 2.0 Security Best Current Practice(RFC 9700)は明確に「Implicit Grantを使用してはならない(MUST NOT)」と定めており、現在はPKCEを伴う認可コードフローへの一本化が業界標準です。

Authorization Code + PKCE

認可コード横取り攻撃(Authorization Code Interception Attack)はなぜ起こるか

認可コードフローは「フロントチャネルに単回使用・短命の引換券だけを流す」ことでリスクを局所化しています。しかし、パブリッククライアントclient_secret を安全に保持できないクライアント。SPAやモバイルアプリ)では、この引換券自体が横取りされるとフロー全体が破綻するという別の弱点が残ります。

典型的な攻撃シナリオは次の通りです。

  1. モバイルアプリは redirect_uri としてカスタムURIスキーム(例: myapp://oauth/callback)を使うことが多い
  2. カスタムURIスキームはOSレベルで「どのアプリが受け取るか」を登録するが、同じスキームを複数のアプリが登録できてしまうOS/バージョンが存在する(スキームの所有権が検証されない)
  3. 悪意あるアプリが同じ myapp://oauth/callback を自分のアプリにも登録しておくと、正規のAuthorization Serverからのリダイレクトを悪意あるアプリが横取りし、code=AUTH_CODE を盗み見ることができる
  4. 正規のクライアント側で client_secret を安全に埋め込めない(アプリのバイナリを解析すれば秘密は取り出せてしまう)ため、client_id だけを知っていれば、盗んだ認可コードで通常のトークン交換リクエストを送ることができてしまう

この問題は「認可コードが機密情報として保護されている」という認可コードフローの暗黙の前提が、パブリッククライアントでは成り立たないことに起因します。つまりトークン交換のリクエストを送ってきた相手が、本当に認可リクエストを開始した張本人と同一であることを証明する手段がないのが根本原因です。

PKCEによる解決:所持証明としての code_verifier

PKCE(RFC 7636)は、この「同一性の証明」という欠けているピースを、TLSや秘密鍵を新たに導入せずに、クライアントだけが知る使い捨ての秘密(プルーフ) で埋める設計です。手順は以下の通りです。

  1. クライアントは認可リクエストを送る前に、暗号学的に安全な乱数から code_verifier を生成する(RFC 7636では43〜128文字の [A-Z a-z 0-9 - . _ ~] を推奨。実質的なエントロピーは256 bit程度取ることが多い)
  2. code_challenge = BASE64URL(SHA256(code_verifier)) を計算する(code_challenge_method=S256
  3. 認可リクエストには code_challenge のみを含める(code_verifier 自体はまだどこにも送らない)
  4. Authorization Serverは code_challenge を認可コードに紐付けて保存し、認可コードを発行する
  5. トークン交換のリクエストで、クライアントは初めて code_verifier を平文で送信する
  6. Authorization Serverは受け取った code_verifier から SHA256BASE64URL を再計算し、ステップ4で保存しておいた code_challenge と一致するかを検証する

なぜこれで攻撃が成立しなくなるかを論理的に整理すると次の通りです。

  • 攻撃者が横取りできるのは認可コードと code_challenge(ハッシュ値)だけであり、code_verifier(生の乱数)はステップ1〜3の間、正規クライアントのメモリ内にしか存在せず、フロントチャネル(URL・リダイレクト)には一度も乗らない
  • code_challenge から code_verifier を逆算するには、SHA-256の原像計算困難性(preimage resistance) を破る必要がある。SHA-256の出力空間は 2^256 通りあり、code_verifier に256 bit相当のエントロピーを持たせれば、総当たりで一致する code_verifier を探索することは計算量的に不可能
  • したがって、攻撃者が横取りした認可コードでトークン交換を試みても、正しい code_verifier を提示できずAuthorization Serverの検証(ステップ6)で必ず拒否される

つまりPKCEは「秘密鍵を安全に保持できないクライアント」の弱点を、「認可リクエストとトークン交換の間だけ有効な使い捨ての知識の証明(Proof of Knowledge)」に置き換えることで解決しています。client_secret のような長期の秘密と異なり、code_verifier は1回の認可フローごとに使い捨てられるため、たとえバイナリ解析などで過去のアプリの秘密が漏れても後続のフローには一切影響しません。

検証: PKCEのcode_verifier/code_challenge生成とS256計算

実際にPythonで code_verifier の生成、code_challenge の計算、Authorization Server側の検証ロジック、そして横取り攻撃のシミュレーションを実装して動作を確認しました。

import base64
import hashlib
import os

# 1. クライアントがランダムな code_verifier を生成する(RFC 7636: 43〜128文字)
code_verifier_bytes = os.urandom(32)  # 256 bit のエントロピー
code_verifier = base64.urlsafe_b64encode(code_verifier_bytes).rstrip(b"=").decode()

# 2. code_challenge = BASE64URL(SHA256(code_verifier)) を計算する(S256 method)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


def verify_pkce(received_verifier: str, expected_challenge: str) -> bool:
    """Authorization Server側の検証: code_verifierからcode_challengeを再計算して一致確認"""
    recomputed = base64.urlsafe_b64encode(
        hashlib.sha256(received_verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode()
    return recomputed == expected_challenge


# 正当なクライアント: 正しいcode_verifierを送る
print(verify_pkce(code_verifier, code_challenge))

# 攻撃者: 認可コードを横取りしてもcode_verifierは知らない
attacker_guess = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
print(verify_pkce(attacker_guess, code_challenge))

実行結果は以下の通りです。

code_verifier  = h3G0qbo8Ob4c8GaxpyugQlo7aX3new5OEizwePGkcf8  (43文字)
code_challenge = pdOVFEzrCrMJJmFNQoJTyU_i_j73Io8wMdtsol3jPsY  (43文字)

正しい code_verifier で検証: True
攻撃者の code_verifier で検証: False

正しい code_verifier では検証が True となる一方、攻撃者が横取りできた code_challenge から別の乱数を推測して送っても検証は False となり、トークン交換は拒否されました。code_verifier のエントロピーは256 bit(2^256 通り)であり、SHA-256の出力空間も同じく 2^256 通りあるため、総当たりでの一致探索は現実的な時間では不可能であることも確認できます。

PKCEの補足:plain メソッドと、なぜ機密クライアントでもPKCEが推奨されるか

code_challenge_method には S256(ハッシュ化)の他に plaincode_challenge = code_verifier とする、後方互換用)も規定されていますが、plain では code_challenge の盗聴がそのまま code_verifier の漏洩に直結するため、新規実装では必ず S256 を使用すべきです。また、たとえ client_secret を安全に保持できる機密クライアント(サーバーサイドWebアプリ)であっても、PKCEを併用することで認可コード横取り攻撃への耐性が二重化されるため、OAuth 2.1のドラフト仕様ではクライアント種別を問わずPKCEが必須化される方向にあります。

Client Credentials Grant

サーバー間通信(Machine-to-Machine)で使用されます。ユーザーは介在しません。

POST /token
  grant_type=client_credentials
  &client_id=CLIENT_ID
  &client_secret=CLIENT_SECRET
  &scope=api:read

非推奨のグラントタイプ

  • Implicit Grant: アクセストークンがURLフラグメントというフロントチャネルで返されるため、ブラウザ履歴・ブラウザ拡張機能・同一ページ内の悪意あるJavaScriptからの読み取りリスクが高い。バックチャネル通信を持たないためリフレッシュトークンも発行できない。RFC 9700で使用が明確に禁止され、PKCEを伴う認可コードフローへの一本化が完了している
  • Resource Owner Password Credentials: ユーザーのパスワードをクライアントに直接渡すため、セキュリティ上の問題が多い(クライアントがパスワードを盗聴・保存できてしまう、多要素認証との相性が悪い等)。OAuth 2.1では廃止予定

アクセストークンとリフレッシュトークン

アクセストークン

APIリクエスト時に Authorization ヘッダーで送信するBearerトークンです。

GET /api/resource
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
  • 有効期限: 通常15分〜1時間と短く設定
  • スコープ: アクセスできるリソースの範囲を制限

リフレッシュトークン

アクセストークンの有効期限が切れた際に、ユーザーの再認証なしで新しいアクセストークンを取得するために使います。

POST /token
  grant_type=refresh_token
  &refresh_token=REFRESH_TOKEN
  &client_id=CLIENT_ID

OpenID Connect(OIDC)

OIDCとは

OIDCはOAuth 2.0の上に構築された認証レイヤーです。OAuth 2.0が「何にアクセスできるか」を扱うのに対し、OIDCは「誰であるか」を扱います。

認可リクエストに scope=openid を含めることで、OIDCフローが有効になります。またOIDCでは認可リクエストに nonce(ランダムな一意値)パラメータを追加できます。クライアントは nonce を生成してセッションに保存し、認可リクエストに含めます。Authorization Serverはこの値をそのままIDトークンのペイロードに埋め込むため、クライアントはIDトークン受信時にセッションに保存しておいた値と一致するかを検証できます(詳細は後述の検証手順を参照)。

IDトークン(JWT)

OIDCでは、アクセストークンに加えてIDトークンが発行されます。IDトークンはJSON Web Token(JWT)形式で、以下の3部分から構成されます。

Header.Payload.Signature

Header:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-123"
}

Payload(標準クレーム):

クレーム説明
subユーザーの一意識別子"user-123"
issトークン発行者"https://auth.example.com"
aud対象クライアント"client-id"
exp有効期限(UNIX時刻)1709942400
iat発行時刻1709938800
nonce認可リクエストで指定した一意値"n-0S6_WzA2Mj"
nameユーザー名"Taro Yamada"
emailメールアドレス"taro@example.com"

Signature: Header + Payload を秘密鍵(RSAやECDSAの秘密鍵、あるいはHMACの共通鍵)で署名し、改ざんを検出します。署名アルゴリズムはHeaderの alg で示され、RS256(RSA PKCS#1 v1.5)・PS256(RSA-PSS)・ES256(ECDSA P-256)・EdDSA(Ed25519)などが使われます。各方式の数学的な仕組みと安全性の違いは デジタル署名(ECDSA/EdDSA/RSA-PSS)の理論とPython実装 で、RSA自体の原理は RSA暗号の理論とPython実装 で詳しく解説しています。

IDトークンの検証手順(完全なチェックリスト)

IDトークンを受け取ったクライアントは、中身をただパースするだけでは不十分で、以下の検証を必ずすべて行う必要があります。それぞれが具体的にどの攻撃を防ぐためのステップかを対応付けて整理します。

  1. 署名検証: Header の kid(Key ID)を使って、Authorization Serverが公開しているJWKS(JSON Web Key Set、通常 /.well-known/jwks.json で取得)から対応する公開鍵を選び、Header + Payload に対する署名を検証する。
    • 防ぐ攻撃: ペイロード改ざん(例えば sub を書き換えてなりすます攻撃)。署名がなければ誰でも任意の内容のトークンを自作できてしまう。
    • 実装上の注意(alg=none攻撃): JWTの alg ヘッダは攻撃者が自由に書き換えられる。検証側が「トークンに書かれている alg をそのまま信用する」実装になっていると、攻撃者は algnone に変更し署名部分を空にしたトークンを送るだけで署名検証をすり抜けられてしまう。これを防ぐには、検証側が期待するアルゴリズム(例: RS256)をコード側で明示的に固定し、トークン側の alg 表明を信用しない実装が必須。
  2. iss(発行者)検証: iss クレームが、事前に信頼している既知のAuthorization ServerのURLと完全一致するか確認する。
    • 防ぐ攻撃: 攻撃者が用意した別のOIDCプロバイダ(悪意あるAuthorization Server、あるいは正規のプロバイダのなりすましドメイン)が発行した、形式上は正しいが信頼できないIDトークンを受理してしまうこと。
  3. aud(対象者)検証: aud クレームが自分自身の client_id と一致するか確認する。
    • 防ぐ攻撃: トークンの不正流用(token substitution/confusion)。同じAuthorization Serverが複数のクライアントにIDトークンを発行している場合、悪意あるクライアントAが自分宛に発行されたIDトークンを、クライアントBのAPIエンドポイントに転送して「クライアントBの正規ユーザーである」と偽装しようとする攻撃を防ぐ。aud を確認しないと、正規に発行された署名済みトークンであっても「誰宛だったか」を無視して受理してしまう。
  4. exp / iat 検証: 現在時刻が exp より前であること、また iat が極端に未来や過去の値でないことを確認する。
    • 防ぐ攻撃: 過去に漏洩した古いIDトークンをいつまでも使い回すリプレイ攻撃。有効期限を短く保つことで、漏洩時の被害時間を限定する。
  5. nonce 検証: IDトークンに含まれる nonce が、認可リクエスト時にクライアントがセッションに保存しておいた値と一致するか確認する。
    • 防ぐ攻撃: IDトークンのリプレイ攻撃。悪意ある第三者が過去に別の認可フローで正規に発行されたIDトークンを盗み、それを別のログインセッションに再送して認証を突破しようとする攻撃を、フローごとに使い捨てる nonce の不一致によって検知・拒否する。

これらはOR条件ではなく**すべて必須(AND条件)**です。1つでも省略すると、対応する攻撃に対して無防備になります。

検証: JWTの生成・署名検証・各種攻撃の拒否をPythonで実演

PyJWTcryptography を使い、RSA鍵ペアでRS256のIDトークンを発行し、上記の検証項目それぞれについて「正常系は通り、攻撃・異常系は拒否される」ことを実際に確認しました。

import time
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

ISSUER, CLIENT_ID, NONCE = "https://auth.example.com", "client-abc123", "n-0S6_WzA2Mj"
now = int(time.time())
payload = {"iss": ISSUER, "sub": "user-123", "aud": CLIENT_ID,
           "exp": now + 3600, "iat": now, "nonce": NONCE}

id_token = jwt.encode(payload, private_pem, algorithm="RS256", headers={"kid": "key-1"})

# 正常な検証
decoded = jwt.decode(id_token, public_pem, algorithms=["RS256"], issuer=ISSUER, audience=CLIENT_ID)
assert decoded["nonce"] == NONCE

実行結果は次の通りでした。

検証ケース結果
正常なトークン(署名・iss・aud・nonce一致)検証成功
sub を書き換えて再署名しない改ざんトークンInvalidSignatureError: Signature verification failed
alg=none かつ署名部を空にしたトークンalgorithms 未指定の脆弱な実装ではpayloadを無検証で読めてしまうが、algorithms=["RS256"] を明示指定した実装では InvalidAlgorithmError: The specified alg value is not allowed で拒否
exp を1時間前に設定した期限切れトークンExpiredSignatureError: Signature has expired
isshttps://evil.example.com に書き換えInvalidIssuerError: Invalid issuer
aud を別クライアントID(client-other-999)に書き換えInvalidAudienceError: Audience doesn't match
nonce がセッション保存値と不一致アプリケーション側のnonce比較で不一致を検知し拒否

特に注目すべきは alg=none 攻撃のケースです。jwt.decode(none_token, options={"verify_signature": False}) のようにアルゴリズムを検証側が指定しない・トークン任せにする実装では、署名が全く存在しないトークンでも subadmin-000 に書き換えたペイロードがそのまま読み取れてしまいました。一方、algorithms=["RS256"] を検証側のコードで明示的に固定した実装では、トークンが宣言する alg: none を受け付けず確実に拒否されることを確認しました。この違いが実装の安全性を分ける最大のポイントです。

UserInfoエンドポイント

アクセストークンを使って追加のユーザー情報を取得できます。

GET /userinfo
Authorization: Bearer ACCESS_TOKEN

セキュリティ上の注意点

stateパラメータ(CSRF対策)

login CSRF攻撃のシナリオ

state パラメータがなぜ必要かを理解するには、まず攻撃が成立する仕組みを見る必要があります。認可コードフローのステップ3(コールバック)は、Authorization Serverからのリダイレクトによってブラウザが REDIRECT_URI?code=AUTH_CODE にアクセスすることで完了します。ここでクライアントのサーバーは「このリクエストのブラウザ(セッション)が、本当に自分が先ほど認可リクエストを開始させたブラウザと同一か」を検証しない限り、攻撃者が用意した認可コードを、被害者のブラウザ経由で送り込まれても区別できません

具体的な攻撃手順(login CSRF)は次の通りです。

  1. 攻撃者は自分自身のアカウントで正規に認可フローを開始し、自分のアカウントに対する認可コード AUTH_CODE_ATTACKER を取得する(トークン交換はまだ行わない)
  2. 攻撃者はこの AUTH_CODE_ATTACKER を含むコールバックURL(REDIRECT_URI?code=AUTH_CODE_ATTACKER)を、画像タグ・自動送信フォーム・悪意あるリンクなどに埋め込み、被害者に踏ませる
  3. 被害者のブラウザがそのURLにアクセスすると、クライアントのサーバーは(state 検証がなければ)疑問を持たずにトークン交換を行い、被害者のセッションに「攻撃者のアカウント」を紐付けてログインさせてしまう
  4. 結果として被害者は、自分では気づかないまま攻撃者のアカウントにログインした状態になる。この状態で被害者がクレジットカード登録や個人情報の入力を行うと、その情報は攻撃者のアカウントに保存され、後で攻撃者がそれを閲覧できてしまう(決済情報の窃取など、シナリオによっては通常のCSRFより深刻な被害になり得る)

stateパラメータによる防御の論理

state パラメータは、認可リクエストとコールバックという2つの別々のHTTPリクエストを、同一ブラウザセッション上で紐付けるための仕組みです。

  1. クライアントは認可リクエストを送る前に、暗号学的に安全な乱数から state を生成し、ユーザーのセッション(サーバーサイドセッションやHttpOnly Cookie)に保存した上で、認可リクエストのパラメータとしても送信する
  2. Authorization Serverはこの state を素通しでコールバックのクエリパラメータに含めて返す
  3. クライアントはコールバック受信時、リクエストに含まれる state と、そのリクエストを送ってきたブラウザのセッションに保存されている state が一致するかを確認する

login CSRF攻撃では、攻撃者は「被害者のセッションに保存されているはずの state の値」を知る手段がありません(攻撃者が知っているのは、自分自身の認可フローで発行された state だけです)。そのため、攻撃者が用意したコールバックURLを被害者に踏ませても、被害者のセッションに保存された state と一致せず、クライアントのサーバーはこのリクエストを拒否できます。これは「フォームにCSRFトークンを埋め込み、送信元のセッションと突き合わせる」という一般的なCSRF対策と全く同じ論理構造です。

検証: state不一致によるCSRF拒否のシミュレーション

Pythonでセッションストアを模した簡易実装を作り、(1) 正規フロー、(2) login CSRF攻撃、(3) state検証を省略した脆弱な実装、の3パターンを比較しました。

import secrets

session_store = {}


def start_authorization_request(session_id: str) -> str:
    state = secrets.token_urlsafe(16)
    session_store[session_id] = {"state": state}
    return state


def handle_callback(session_id: str, returned_state: str) -> bool:
    expected_state = session_store.get(session_id, {}).get("state")
    return expected_state is not None and secrets.compare_digest(expected_state, returned_state)

実行結果は以下の通りでした。

シナリオstate検証の結果
正規フロー(発行された state がそのままコールバックで返る)True(受理)
login CSRF攻撃(攻撃者の state を被害者のセッションに送る)False(拒否)
state 検証を行わない脆弱な実装常に True(攻撃が成立してしまう)

正規フローでは発行した state とコールバックの state が一致し True が返る一方、攻撃者が自分のフローで取得した stateattacker-controlled-state-xyz)を被害者のセッションに対して提示した場合は、被害者のセッションに保存されている state と一致しないため False となり拒否されることを確認しました。もし state 検証自体を省略した実装(handle_callback_without_state_check)であれば、この不一致を無視して常に受理してしまうため、login CSRFが成立してしまうことも合わせて確認しています。

PKCEの必須化

OAuth 2.1ではすべてのクライアントタイプでPKCEが必須となる見込みです。新規実装ではPKCEを必ず使用してください。

トークンの保管

クライアント種別推奨保管場所注意点
サーバーサイドWebアプリサーバーのセッションHttpOnly Cookieで参照
SPAメモリ内(変数)localStorageは非推奨(XSSリスク)
モバイルアプリOS提供のセキュアストレージKeychain / Keystore

redirect_uriの検証

Authorization Serverは、事前に登録された redirect_uri と完全一致するかを検証する必要があります。ワイルドカードや部分一致を許可すると、オープンリダイレクト攻撃のリスクがあります。

まとめ

項目OAuth 2.0OpenID Connect
目的認可(リソースアクセス制御)認証(ユーザー身元確認)
主要トークンアクセストークンIDトークン(JWT)
スコープread, writeopenid, profile, email
用途API保護SSO、ログイン

関連記事

参考文献