権限設計プラクティス

API連携を安全にする OAuth 2.0/OpenID Connect 権限スコープの設計と実践

Tags: OAuth 2.0, OpenID Connect, スコープ, APIセキュリティ, 認可, 権限設計, FastAPI

はじめに:API連携と権限管理におけるスコープの重要性

現代のシステム開発において、他のサービスとのAPI連携は不可欠です。ユーザーデータの取得、外部サービスの機能利用など、様々なシナリオでAPIが活用されています。しかし、API連携においては、連携先のシステムに対して「どこまでの操作を許すか」という権限管理が極めて重要になります。不適切な権限付与は、情報漏洩や意図しないデータ操作といった深刻なセキュリティリスクを招く可能性があるためです。

ここで中心的な役割を果たすのが、OAuth 2.0およびOpenID Connectにおける「スコープ」という概念です。スコープは、クライアントアプリケーションが保護リソース(APIなど)に対して要求する、あるいは認可サーバーが付与する権限の範囲を定義します。適切にスコープを設計・利用することで、アプリケーションが必要最低限の権限のみを要求・使用できるようになり、セキュリティの向上に繋がります。

本記事では、経験数年程度のソフトウェアエンジニアの皆様が、日々の開発業務で直面するAPI連携時の権限管理の課題解決に役立てていただけるよう、OAuth 2.0およびOpenID Connectにおけるスコープの基本概念から、実践的な設計方法、そしてアプリケーションでの具体的な使い方までを詳しく解説します。

OAuth 2.0とOpenID Connectにおけるスコープの基本概念

スコープとは何か?

スコープ(Scope)は、OAuth 2.0やOpenID Connectにおいて、クライアントアプリケーションがアクセスできる保護リソース(例えばAPI)の範囲や、実行できる操作の種類を限定するために使用される識別子です。認可サーバーは、ユーザーの同意やポリシーに基づいて、クライアントに許可するスコープを決定し、発行されるアクセストークンにその情報を付与します。リソースサーバー(APIを提供する側)は、受け取ったアクセストークンに含まれるスコープを確認することで、クライアントからのリクエストが許可された操作範囲内であるかを検証します。

OAuth 2.0におけるスコープの役割

OAuth 2.0は、認可(Authorization)のためのフレームワークです。ユーザーに代わって、特定のアプリケーション(クライアント)に保護リソースへのアクセス権限を限定的に委任することを目的としています。スコープは、この「限定的なアクセス権限」の範囲を具体的に指定するために使われます。

例: * read:data: データの読み取りを許可 * write:data: データの書き込みを許可 * delete:user: ユーザーの削除を許可

クライアントは認可リクエスト時に必要なスコープを認可サーバーに伝えます。認可サーバーは、ユーザーの同意を得た上で、要求されたスコープの中から許可するものを選択し、アクセストークンに含めて発行します。

OpenID Connectにおけるスコープの役割

OpenID Connect(OIDC)は、OAuth 2.0をベースにした認証(Authentication)レイヤーです。ユーザーのID検証や、ユーザーに関する基本的な属性情報(プロファイル情報)の取得を目的としています。OIDCにおけるスコープは、OAuth 2.0の役割に加え、主に認証結果として取得できるユーザー属性(Claim)の種類を指定するためにも使用されます。

OIDCにはいくつかの標準スコープが定義されています。

これらの標準スコープに加えて、OAuth 2.0と同様にカスタムスコープを定義し、アプリケーション固有の認可情報を扱うことも可能です。

標準スコープとカスタムスコープ

実践的なスコープ設計

安全かつ効率的なシステムを構築するためには、慎重なスコープ設計が求められます。以下の点を考慮してスコープを設計することをお勧めします。

1. 最小権限の原則に基づいた設計

セキュリティの基本原則の一つに「最小権限の原則(Principle of Least Privilege)」があります。これは、ユーザーやシステムには、そのタスクを遂行するために必要最低限の権限のみを与えるべきであるという考え方です。スコープ設計においてもこの原則を適用します。

2. APIエンドポイントやリソースに応じたスコープ定義

マイクロサービスアーキテクチャなど、APIが多数存在する環境では、APIエンドポイントやリソースごとにアクセス制御を行うことが一般的です。スコープをこれらの単位に対応させて定義すると、リソースサーバーでの検証が容易になります。

例:/usersエンドポイントへのGETリクエストにはread:usersスコープ、/ordersエンドポイントへのPOSTリクエストにはwrite:ordersスコープが必要、といった設計です。

スコープ命名規則として、{操作}:{リソース名}のような形式を採用すると、分かりやすく管理しやすくなります。例: get:users, post:users, get:orders/{id}, put:orders/{id}, delete:orders/{id}など。あるいは、特定のサービスや機能に関連付けたスコープ名も有効です。例: payment:process, shipping:update_status.

3. クライアントの種類とスコープの関連性

前述の通り、クライアントの種類によって必要な権限は異なります。

認可サーバーは、クライアントの種類(Client Type)や登録されている情報に基づいて、要求されたスコープがそのクライアントに付与可能かを判断することも重要です。

4. スコープ命名規則の重要性

一貫性のある分かりやすいスコープ命名規則を採用することで、開発者や運用者がスコープの意味を正確に理解しやすくなります。

5. スコープ設計時の考慮事項

アプリケーションでのスコープの使い方(実装例)

実際にクライアントアプリケーション、認可サーバー、リソースサーバーがどのようにスコープを扱うかを見ていきましょう。

クライアント側:認可リクエストでのスコープ指定

クライアントアプリケーションは、ユーザーに代わって保護リソースへのアクセスを要求する際、認可サーバーへのリクエストに希望するスコープを含めます。これは通常、認可エンドポイントへのリダイレクトURLのクエリパラメータとして渡されます。

GET /authorize?
  response_type=code&
  client_id=s6BhdRkqt3&
  state=xyz&
  redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&
  scope=openid%20profile%20email%20read%3Aorders

(URLエンコードされているため、scope=openid profile email read:orders をURLエンコードした結果です。)

ここで、scopeパラメータに複数のスコープをスペース区切りで指定しています。認可サーバーは、このリクエストを受け取り、ユーザーにこれらのスコープに対するアクセス許可を求める同意画面を表示します。

リソースサーバー側:アクセストークンに含まれるスコープの検証

リソースサーバー(APIを提供する側)は、クライアントからのリクエスト時に提供されたアクセストークンを受け取ります。このアクセストークンを認可サーバー(または専用のイントロスペクションエンドポイントなど)で検証し、トークンが有効であること、そしてリクエストされた操作を実行するために必要なスコープが含まれていることを確認します。

アクセストークンがJWT(JSON Web Token)形式である場合、スコープ情報はPayload部分にクレームとして含まれていることが多いです(例: "scope": "read:orders write:orders")。不透明なトークン(opaque token)の場合は、認可サーバーのイントロスペクションエンドポイントに問い合わせてスコープ情報を取得します。

リソースサーバーでは、APIエンドポイントごとに要求されるスコープを定義しておき、リクエストが来るたびにアクセストークンのスコープと照合します。

Python + FastAPI でのリソースサーバー側のスコープ検証の簡略例:

from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import OAuth2BearerToken

# ダミーのトークン検証関数 (実際には認可サーバーと連携)
# この例では、トークン文字列からスコープ情報を取得すると仮定
def get_token_scopes(token: str) -> set[str]:
    # 実際のアプリケーションでは、ここで認可サーバーの
    # トークンイントロスペクションエンドポイントを呼び出すなどして
    # トークンの有効性確認とスコープ情報の取得を行います。
    # ここは簡略化のため、特定のトークン文字列にスコープを紐付けます。
    if token == "valid_token_with_read_orders":
        return {"read:orders"}
    elif token == "valid_token_with_write_orders":
        return {"write:orders"}
    elif token == "valid_token_with_all_orders":
        return {"read:orders", "write:orders"}
    else:
        # 無効なトークンとして扱う
        return set()

# カスタムOAuth2BearerTokenクラスを作成し、スコープ検証を組み込む
class ScopedOAuth2BearerToken(OAuth2BearerToken):
    def __init__(self, auto_error: bool = True):
        super().__init__(auto_error=auto_error)

    # __call__ メソッドをオーバーライドしてスコープ検証を追加
    async def __call__(self, request) -> str:
        token = await super().__call__(request) # まずは標準のトークン検証
        # ここでスコープ検証を行うロジックを追加
        required_scopes: set[str] = getattr(request.scope["route"], "required_scopes", set())

        if required_scopes:
            provided_scopes = get_token_scopes(token)
            if not provided_scopes.issuperset(required_scopes):
                 raise HTTPException(
                    status_code=403, # Forbidden
                    detail="Insufficient scope",
                    headers={"WWW-Authenticate": self.scheme_name},
                )
        return token # 検証OKならトークンを返す

# カスタム依存性注入可能なオブジェクトを作成
oauth2_scheme = ScopedOAuth2BearerToken()

# エンドポイントに関数を追加してスコープを定義
def require_scopes(*scopes: str):
    def decorator(func):
        # 依存性注入のリストにoauth2_schemeを追加
        if not hasattr(func, "__dependencies__"):
            func.__dependencies__ = []
        # ScopedOAuth2BearerTokenのインスタンスを依存性として追加
        func.__dependencies__.append(Depends(oauth2_scheme))
        # 必要なスコープを関数オブジェクトに付与(ScopedOAuth2BearerToken.__call__ で参照される)
        # FastAPIの内部構造を利用しているため、プロダクションコードではライブラリ等の利用を推奨
        # 例として、依存関係の解決時にrouteオブジェクトにアクセスできることを利用
        func.required_scopes = set(scopes)
        return func
    return decorator

app = FastAPI()

# スコープ read:orders が必要
@app.get("/orders")
@require_scopes("read:orders")
async def read_orders(token: str = Depends(oauth2_scheme)):
    # ここに orders を読み取るロジック
    return {"message": "Orders data", "scopes": get_token_scopes(token)}

# スコープ write:orders が必要
@app.post("/orders")
@require_scopes("write:orders")
async def create_order(token: str = Depends(oauth2_scheme)):
     # ここに order を作成するロジック
    return {"message": "Order created", "scopes": get_token_scopes(token)}

# スコープ read:users が必要 (この例では有効なトークンがないためアクセス不可)
@app.get("/users")
@require_scopes("read:users")
async def read_users(token: str = Depends(oauth2_scheme)):
    return {"message": "Users data", "scopes": get_token_scopes(token)}

# スコープが不要なエンドポイント
@app.get("/")
async def public_endpoint():
    return {"message": "Public access"}

上記の例では、@require_scopes("...")デコレーターを使って、各APIエンドポイントに必要とされるスコープを指定しています。ScopedOAuth2BearerTokenクラスがトークンを受け取り、定義されたスコープが含まれているかを確認し、含まれていなければ403 Forbiddenエラーを返します。実際には、トークン検証やスコープ抽出の部分は、OAuth/OIDCライブラリや認可サーバーとの連携によって実装されます。

認可サーバー側:ユーザーの同意画面とスコープ付与ロジック

認可サーバーは、クライアントから要求されたスコープをユーザーに提示し、同意を得るための画面(同意画面、Consent Screen)を表示します。ユーザーはこの画面で、クライアントに許可するスコープを選択または確認します。

認可サーバーは、以下の要素に基づいて最終的に発行するアクセストークンに含めるスコープを決定します。

例えば、クライアントがread:orderswrite:ordersを要求し、ユーザーがread:ordersのみに同意した場合、発行されるアクセストークンにはread:ordersスコープのみが含まれます。

スコープ管理の注意点とベストプラクティス

スコープの変更管理

スコープ定義はシステムのAPIと共に進化していく可能性があります。新しい機能の追加に伴い新しいスコープが必要になったり、既存のスコープの定義を変更したりすることがあります。

動的スコープ vs 静的スコープ

ほとんどのOAuth/OIDC実装では、事前に定義されたスコープの中からクライアントが選択して要求する、という半静的な運用が一般的です。

セキュリティリスク

デバッグ時のスコープ確認方法

開発中や運用中にアクセストークンに含まれるスコープを確認したい場合があります。

まとめ

OAuth 2.0およびOpenID Connectにおけるスコープは、API連携を安全に行うための重要な要素です。スコープを適切に設計し、最小権限の原則に基づいた権限管理を実践することで、システム全体のセキュリティレベルを向上させることができます。

本記事では、スコープの基本的な概念から、APIエンドポイントやリソースに応じた具体的な設計方法、そしてクライアント・リソースサーバーにおける実装のポイントまでを解説しました。API連携機能を開発する際には、ぜひスコープ設計に時間をかけ、各システムコンポーネントでスコープが正しく扱われるように注意してください。

権限設計は、一度行えば終わりというものではなく、システムの進化に合わせて見直しや改善を継続的に行う必要があります。本記事で解説した内容が、皆様のシステム開発における安全な権限管理の一助となれば幸いです。