SaaSにおけるIdP実装の抽象化とDI活用による柔軟な設計
1. はじめに
はじめまして、株式会社neoAIでソフトウェアエンジニアをしている加藤と申します。
SaaSプロダクトを開発していると、クライアントや環境ごとに異なる認証方式に対応する必要があります。オンプレミス環境、クラウド環境、大手企業からスタートアップまで、多様なニーズに応えるためには、IdP(Identity Provider)を柔軟に切り替えられる仕組みが求められます。
以前、弊社のMoriyasuが執筆した「FastAPI の Dependency Injection (Depends + Injector)」では、FastAPIとDIの基本的な活用方法や、プロダクトへの導入事例をご紹介しました。
本記事ではそれを踏まえ、FastAPIのDIを活用して、SaaSプロダクトにおけるIdP(Identity Provider)の実装をいかに抽象化し、柔軟に切り替え可能な構成を実現するかを解説します。特に、環境変数によるIdPの注入とその抽象化設計によって、保守性や拡張性、可読性をどう高められるかに焦点を当てていきます。
2. SaaSにおけるIdP使い分けの背景
企業によって使用するIdPは異なります。Auth0、Microsoft Azure AD、Google Workspace、Oktaなどのクラウド型IdPから、オンプレミスで動作するActive DirectoryやKeycloakまで多岐に渡ります。さらに、SAML、OIDC、LDAPなどプロトコルレベルでも要件が変わるため、認証の実装は柔軟かつ堅牢である必要があります。
IdPについて詳しく知りたい場合はリンクのような記事を参照ください。
実際のユースケース:IdP抽象化に至った背景
弊社では当初、クラウド環境でのSaaS提供を前提にAuth0をIdPとして採用していました。しかし、事業拡大に伴い、セキュリティ要件の厳しい企業など、パブリッククラウドの利用が制限されている顧客からの要望に応えるため、オンプレミス環境や閉域網でのSaaS展開が必要になりました。
この新たな要件に対応するため、オープンソースのIdPであるKeycloakを採用することになりましたが、既存のAuth0向けのコードベースをそのまま維持しつつ、Keycloakにも対応する必要がありました。そこで我々は、IdPの実装を抽象化し、環境に応じて適切なIdP実装を注入できる設計を採用しました。
結果として、コードベースの大幅な改修なしに新しいIdPに対応でき、将来的に別のIdP(例:Azure ADやGoogle Identity Platform)が必要になった場合でも、最小限の変更で対応できる保守性・拡張性・可読性に優れた設計を実現することができました。
3. よくある実装の課題
SaaSの拡大や要件の多様化に伴い、複数のIdPに対応する必要が生じた場合、多くの開発者が最初に選択するアプローチは、条件分岐による実装です。環境変数やテナント情報に基づいて、適切なIdPを選択する「if-elif-else」の構造は、短期的には簡単で直感的に実装できます。
しかし、このアプローチはシステムの成長とともに様々な問題を引き起こします。特に、複数の開発者が長期間にわたって協業する大規模プロダクトでは、技術的負債として蓄積されていきます。
例えば、以下のような条件分岐による実装は、最初は単純に見えますが、後々の保守性に大きな課題をもたらします
if os.getenv("IDP") == 'azure':
use_azure_ad()
elif os.getenv("IDP") == 'google':
use_google_workspace()
elif os.getenv("IDP") == 'ldap':
use_ldap()
このような実装は、次のような課題を引き起こします
- IdPが増えるたびに分岐が増え、コードが読みにくくなる
- テストが煩雑になる(全パターンをテストする必要がある)
- 条件分岐のミスにより、意図しない挙動が起こる可能性がある
- 将来的な仕様変更時に、全体の修正範囲が広がる
さらに、抽象化を行わない場合の大きな問題点として、IdPごとに関数が個別に生えてしまい、以下のような実装を強いられるケースがあります:
ユニオン型による煩雑なTypeチェックの例
Pythonの型ヒントを活用した場合でも、複数のIdP実装を扱うために、以下のようなユニオン型とランタイムでの型チェックを組み合わせたコードが必要になります。これは静的型付け言語でも同様の問題が発生します。
from typing import Union
idp: Union[AzureIdPHandler, GoogleIdPHandler]
if isinstance(idp, AzureIdPHandler):
result = idp.specific_azure_method()
elif isinstance(idp, GoogleIdPHandler):
result = idp.specific_google_method()
このようなパターンは、以下のリスクを抱えます。
- 各関数呼び出し前に型チェックが必要になる
- 共通インターフェースが存在しないためテストがしにくい
- 変更が重なるとコードのスパゲッティ化につながる
- IDEの自動補完や静的解析ツールのサポートが限定的になる
- 新しいIdPを追加するたびに、すべての型チェック箇所を修正する必要がある
さらに、このアプローチではカプセル化の原則が崩れ、IdPごとの処理が横断的にアプリケーション全体に散らばることで、責務の分離が困難になり、拡張性が著しく損なわれます。また、ビジネスロジックと認証処理の境界が曖昧になり、コードの再利用性も低下します。
4. 解決策:DIによるIdPの抽象化
ここでは、実際のソースコードを用いて、環境変数とDependency Injection(DI)を活用してIdPを抽象化した実装を紹介します。この設計では、「利用する側はインターフェースだけを知っていれば良い」という原則に基づいています。
今回は主にクラウドで利用しているAuth0、オンプレで利用しているKeycloakを題材とします。
図によるDI実装のイメージ
最初に図によるDI実装のイメージを共有します。DIを活用したIdPの抽象化は以下の3つのステップで実現できます。
- エンドポイントの作成とインターフェース定義
この図は、エンドポイントが抽象インターフェースのみに依存している状態を示しています。エンドポイントは具体的なIdP実装(Auth0やKeycloak)について何も知らず、抽象インターフェースを通じてトークン検証やユーザー情報取得を行います。
- 抽象クラスの実態(具象クラス)を実装
各IdPの具体的な実装(Auth0とKeycloak)は、共通のインターフェースを実装しています。これにより、それぞれのIdPの独自の処理方法を保ちながらも、共通のインターフェースを通じてアプリケーションと連携できます。
- 抽象クラスへ実態をDIするイメージ
最後に、環境変数に基づいて、DIコンテナが適切な具象クラスのインスタンスを生成し、エンドポイントに注入します。これにより、コードを変更することなく、環境変数の設定だけでIdPの切り替えが可能になります。
この図で重要なのは、アプリケーションコード(右側)が抽象インターフェースのみに依存し、具体的な実装(左側)とは直接結合していない点です。DIコンテナが中間に入ることで、この疎結合が実現され、柔軟な設計が可能になっています。
メインアプリケーション
ソースコードを用いてDIについて解説していきます。
FastAPIのDependsを利用し、実行時に環境変数に応じて適切なIdPの実装を自動的に注入します。DependsはFastAPIが提供する依存関係注入の仕組みで、リクエスト処理中に必要なコンポーネントを動的に解決できます。
プロダクトコードではDDDとクリーンアーキテクチャを採用しており、FastAPIのDependsではDIコンテナの設計が複雑になるためInjectorを利用していますが、本記事では分かりやすさを優先しInjectorを使わない実装で説明します。
import logging
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.abc.idprovider.handler import IdPHandler
from app.impl.idprovider import handler as idp_handler
logging.basicConfig(
level=logging.INFO,
format=(
'{'
'"time": "%(asctime)s", '
'"level": "%(levelname)s", '
'"message": "%(message)s"'
'}'
),
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
logger = logging.getLogger(__name__)
app = FastAPI()
# HTTPBearerスキーマを使用
security = HTTPBearer()
# トークン検証のための依存関係
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
handler: Annotated[IdPHandler, Depends(idp_handler.get)],
) -> dict:
token = credentials.credentials
is_valid = await handler.validate_token(token)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="トークンが無効です",
headers={"WWW-Authenticate": "Bearer"},
)
return await handler.get_user_info(token)
@app.get("/user-info")
async def get_user_info(
current_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
return current_user
特に注目すべき点は、get_current_user
関数の引数宣言部分です。ここではDepends(idp_handler.get)
を使って、抽象インターフェース型のIdPHandler
を注入しています。この関数は具体的な実装(Auth0やKeycloak)について何も知らず、単に抽象インターフェースを通じてメソッドを呼び出しているだけです。これにより、利用側のコードが特定のIdP実装に依存せず、インターフェースにのみ依存する形になっています。
抽象クラス(ABC)
IdPが実装すべきインターフェースを定義します。これがDIの核となる部分で、すべてのIdP実装が共通して持つべき機能を定義しています。
from abc import ABC, abstractmethod
class IdPHandler(ABC):
@abstractmethod
async def validate_token(self, token: str) -> bool:
pass
@abstractmethod
async def get_user_info(self, user_id: str) -> dict:
pass
この抽象クラスは、トークン検証とユーザー情報取得という、どのIdPでも必要となる基本機能を定義しています。@abstractmethod
デコレータにより、これらのメソッドは具象クラスで必ず実装しなければならないことを示しています。このインターフェースを介してコードを書くことで、具体的なIdP実装に依存せずに認証ロジックを記述できます。
DIファクトリーと具体的な実装
環境変数によりIdPの実装を選択・注入するファクトリーを実装します。これがDIコンテナの役割を果たし、実行時に適切なIdP実装を選択します。
import os
from app.impl.idprovider.auth0_handler import Auth0IdPHandler
from app.impl.idprovider.keycloak_handler import KeycloakIdPHandler
class IdPHandlerSingleton:
_instance = None
@classmethod
async def get_instance(cls):
if cls._instance is None:
idp_type = os.getenv("ID_PROVIDER_TYPE")
if idp_type == "auth0":
cls._instance = Auth0IdPHandler()
elif idp_type == "keycloak":
cls._instance = KeycloakIdPHandler()
else:
raise ValueError(f"Invalid IDP type: {idp_type}")
return cls._instance
async def get():
return await IdPHandlerSingleton.get_instance()
SingletonパターンとFactoryパターンを組み合わせていますが、get関数が呼ばれた時に適切な具象クラスのインスタンスを返却できれば問題ありません。
具体的なIdP実装例
Auth0実装例
from logging import Logger, getLogger
from app.abc.idprovider.handler import IdPHandler
default_logger = getLogger(__name__)
class Auth0IdPHandler(IdPHandler):
def __init__(self, logger: Logger = default_logger) -> None:
self.logger = logger
async def validate_token(self, _token: str) -> bool:
self.logger.info("Auth0IdPの検証")
return True
async def get_user_info(self, _token: str) -> dict:
self.logger.info("Auth0IdPのユーザー情報の取得")
# 本来はIdPからトークンの中身を解析してユーザー情報を取得する
# IdPのUserInfoエンドポイントを叩いてユーザー情報を取得することも可能
return {"user_id": "dummy_user_id", "name": "Auth0IdPUserName"}
Auth0IdPHandlerは抽象インターフェースIdPHandler
を実装しています。実際のプロダクションコードでは、Auth0のSDKやAPIを使用してトークンの検証やユーザー情報の取得を行いますが、ここではサンプルとして簡略化しています。
この実装の重要なポイントは、抽象インターフェースで定義されたメソッドを実装していることで、これによりアプリケーションはこのクラスを抽象インターフェースを通じて操作できます。また、ロギング機能もコンストラクタインジェクションによって注入可能になっており、テスト時にログの検証も容易になっています。
JWT(JSON Web Token)の検証について詳しく知りたい方は、以下のRFCドキュメントが参考になるので、これらを読むと雰囲気理解できると思います。
Keycloak実装例
from logging import Logger, getLogger
from app.abc.idprovider.handler import IdPHandler
default_logger = getLogger(__name__)
class KeycloakIdPHandler(IdPHandler):
def __init__(self, logger: Logger = default_logger) -> None:
self.logger = logger
async def validate_token(self, _token: str) -> bool:
self.logger.info("KeycloakIdPの検証")
return True
async def get_user_info(self, _token: str) -> dict:
self.logger.info("KeycloakIdPのユーザー情報の取得")
# 本来はIdPからトークンの中身を解析してユーザー情報を取得する
# IdPのUserInfoエンドポイントを叩いてユーザー情報を取得することも可能
return {"user_id": "dummy_user_id", "name": "KeycloakIdPUserName"}
Keycloakを使用する実装も、同じ抽象インターフェースに従っています。実際の実装では、KeycloakのSDKやAPIを利用してトークンの検証やユーザー情報の取得を行いますが、ここではサンプルとして簡略化しています。
この両方の実装は同じインターフェースを実装しているため、アプリケーションから見ると、どちらの実装を使っているかは関係なく、同じメソッドを同じ引数で呼び出せば良いことになります。これにより、コードの利用側は実装の詳細を知る必要がなく、抽象インターフェースを通じた一貫した操作が可能になっています。
おまけ: 実際の利用シーン
この実装例では、わかりやすさのためにシンプルなAPIエンドポイントを示していますが、サンプルコード内のIdPHandlerのget_user_info関数はget_current_user
関数内でトークン検証に成功した後に呼び出されているだけです。
実際の実装ではIdPHandlerには多くの関数があり、例えばIdPのユーザーCRUD操作を行う際にも同じインターフェースを使用できます。クリーンアーキテクチャの観点から見ると、これはユースケース層から呼び出されることが多く、その場合にも同様の依存注入パターンが必要になります。
複雑なアプリケーションでは、このような依存関係をすべてFastAPIのDependsだけで管理するのは煩雑になるため、弊社ではInjectorを採用してDIコンテナをより体系的に管理しています。以前の記事「FastAPI の Dependency Injection (Depends + Injector)」でもこの点について触れていますので、興味がある方はぜひご覧ください。
5. このアプローチによる恩恵
このDIを活用した抽象化アプローチにより、多くの利点が得られます。具体的には以下のような恩恵があります。
実行速度の最適化
- アプリケーション起動時に環境変数に応じた必要なIdPだけを初期化するため、不要な処理を避けられ、実行速度が向上。
保守性の向上
- 新しいIdPを追加する場合でも、インターフェースに従った具象クラスを作成するだけで済むため、既存コードへの影響が最小限になる。
- 変更範囲が明確になり、将来的な仕様変更や機能追加も容易になる。
テストのしやすさ
- 共通インターフェースが定義されているため、IdPごとのモックやスタブを簡単に作成でき、ユニットテストの効率が向上。
- DIを活用することで、テスト環境での差し替えが簡単に行えるため、統合テストやE2Eテストも簡単になる。
コードの可読性
- 各IdPの処理が明確に分離されることで、コードの役割が明快になり、メンテナンス時の理解が容易になる。
- 複雑な条件分岐が不要になるため、ロジックがシンプルで読みやすくなる。
Typeチェックの不要化
- 共通のインターフェースを通じて各IdPが実装されているため、型チェック(isinstanceなど)が不要になり、コードの複雑性が大幅に削減。
- 静的型検査ツール(mypyなど)との相性も良くなり、コードの安全性が向上。
6. 抽象化の落とし穴と注意点
抽象化とDependency Injection(DI)には数多くのメリットがありますが、設計や実装の際には以下のような注意点があります。
オーバーエンジニアリングの回避
- 必要以上に抽象化を進めると、逆にコードの複雑性が増し、理解や保守が難しくなる。
- 使用するIdPが少数で、これ以上増える可能性がない場合、DIがかえって負担になることもあるため、状況に応じて適切なレベルの抽象化を判断する必要がある。
チーム内での設計意図共有
- 抽象化した設計の意図やDIの仕組みについて、チーム内で十分な理解と共有がないと、正しく運用・拡張できなくなる恐れがある。
- 定期的なレビューやドキュメンテーションを通じて、設計意図や実装方法を明確に伝えることが重要。
インターフェース設計の適切さ
- インターフェース設計が不適切だと、各IdPの特性を活かせず、無理に共通化してしまうことにより、拡張性や柔軟性が損なわれる場合がある。
- 各IdPが提供する機能の差異を考慮した上で、共通化できる範囲を慎重に決定し、インターフェースを設計することが求められる。
7. 終わりに
本記事では、SaaS開発におけるIdP実装の設計課題とその解決方法を解説しました。私たちが実際に経験したように、クラウドとオンプレミスの両環境に対応する必要性は、多くのSaaS企業が直面する課題です。
DIを活用した抽象化アプローチは、この課題に対する効果的な解決策であり、以下の点で大きな価値をもたらします。
-
環境に応じた柔軟な切り替え: 環境変数の設定だけでIdPを切り替えられるため、デプロイ先ごとに適切な認証基盤を選択できる。
-
コードの一貫性: 抽象インターフェースを通じて認証処理を行うため、IdPが変わってもアプリケーションコードは変更不要。
-
拡張性: 新しいIdPへの対応が必要になった場合も、新たな具象クラスを追加するだけで対応できる。
-
テスト容易性: モックやスタブを使ったテストが容易になり、品質担保がしやすくなる。
今後の発展
今後の発展としては、以下のような拡張が考えられます
- 認証フロー多様化: OAuth2.0のさまざまなフローや、SAML、LDAP等の異なるプロトコルへの対応
- 権限管理の統合: 認証だけでなく、認可(Authorization)の仕組みも抽象化し、統合的なアイデンティティ管理を実現
このような抽象化とDIの考え方は、認証基盤だけでなく、データストア(RDBやNoSQL、KVSなど)やメッセージキューなど、他のインフラコンポーネントにも応用可能です。ぜひ皆さんのプロジェクトでも、適切な抽象化とDIを活用して、柔軟で保守性の高いシステム設計を実践してみてください。
認証基盤の設計に悩んでいる方の参考になれば幸いです。ご質問・フィードバックをお待ちしています!
Discussion