Auth0を用いたセキュアなFastAPIサーバの実装について

に公開

今回は、Auth0を用いてFastAPIサーバをセキュアに構築する方法について共有します。

Auth0とは?

Auth0は認証認可を簡単に実装するために利用できるプラットフォームであり、ECサイトからアプリケーション開発者まで幅広く利用されています。様々なアプリケーションに簡単に組み込むことができる上に、実現できるセキュアな状態のレベルが高いことから、多くの場面で利用されていることを目にします。

https://auth0.com/

今回やりたいこと

今回はFastAPIサーバを立てる時に、Auth0を用いてアクセス制限等の認証認可を実現したいと思い調べていたところ、以下のチュートリアルが用意されていることを発見し、やってみようと思いました。FastAPIを利用する上でセキュアな構成を取る方法はいくつもあると思いますが、今回はAuth0と組み合わせる方法を身につけたいと思っていたのでこちらをやってみます。

https://auth0.com/blog/build-and-secure-fastapi-server-with-auth0/

それではチュートリアルをやってみましょう!

このチュートリアルを実施するにあたり、Auth0はすでに利用できる環境であることを前提としたいと思います。

Python環境構築

今回はuvを利用して以下のように環境を構築しました(チュートリアルとは少し違います)。

uv init fastapi_auth0 -p 3.12
cd fastapi_auth0
uv add fastapi 'uvicorn[standard]' pydantic-settings 'pyjwt[crypto]'

uvについてはこちらでも解説しているのでぜひ参考にしてください。

https://zenn.dev/akasan/articles/39f81f8bd15790

パブリックアクセスを実装する

まずはAuth0を利用せずパブリックアクセスできるようなAPIを実装したいと思います。

public_router.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/api/public")
def public():
    """No access token required to access this route"""

    result = {
        "status": "success",
        "msg": ("Hello from a public endpoint! You don't need to be "
                "authenticated to see this.")
    }
    return result
main.py
from fastapi import FastAPI
from public_router import router as public_router

app = FastAPI()
app.include_router(public_router)

それでは実行してパブリックサーバを呼び出してみましょう。サーバの起動には以下のコマンドを用います。

uv run uvicorn main:app --host localhost --port 8000

その状態で今回はcurlを用いてアクセスしてみます。実行すると設定したレスポンスが帰ってくることが確認できます。

curl -X 'GET' --url http://127.0.0.1:8000/api/public

# 結果
{"status":"success","msg":"Hello from a public endpoint! You don't need to be authenticated to see this."}

プライベートアクセスを実装する

それでは次にプライベートアクセスを求めるエンドポイントを実装してみます。テストのため、Auth0を利用せずベアラートークンが設定されているかを確認する仕組みを使ってプライベートアクセスをさせてみます。

priavte_router.py
from fastapi import APIRouter, Depends
from fastapi.security import HTTPBearer  # 👈 new imports

router = APIRouter()
token_auth_scheme = HTTPBearer()  # 👈 new code

@router.get("/api/private")
def private(token: str = Depends(token_auth_scheme)):
    """A valid access token is required to access this route"""

    result = token.credentials

    return result

main.py
from fastapi import FastAPI
from public_router import router as public_router
from private_router import router as private_router

app = FastAPI()
app.include_router(public_router)
app.include_router(private_router)

それでは先ほどと同じように実行してみます。サーバを立てた状態でプライベートAPIにアクセスしてみると、以下のようにエラーがでます。

curl -X 'GET' --url http://127.0.0.1:8000/api/private

# 結果
{"detail":"Not authenticated"}

これはベアラートークンが設定されていないので認証されていないことを意味しています。試しにヘッダーにベアラートークン(今回は適当な文字列を指定しています)を指定するとアクセスが成功します。

curl -X 'GET' --url http://127.0.0.1:8000/api/private --header 'Authorization: Bearer hogehogefugafuga'

# 結果
"hogehogefugafuga"

Auth0 APIの設定をする

次からAuth0を利用していきます。それにあたり、Auth0 APIの設定をします。APIの作成はApplicationsAPIsのように選択します。

作成画面が開いたら以下のように入力します。

Auth0周りの環境変数などの設定

次にAuth0に関する環境変数を設定していきます。.envに以下の内容を設定してください。各情報はAuth0のダッシュボードから取得してください。

AUTH0_DOMAIN = your.domain.auth0.com
AUTH0_API_AUDIENCE = https://your.api.audience
AUTH0_ISSUER = https://your.domain.auth0.com/
AUTH0_ALGORITHMS = RS256

アプリケーション設定の実装

次にAuth0と連携するためのアプリケーション設定を実装します。

まずは環境変数ファイルを読み込み設定を取得するためのコードを実装します。

applications/config.py
from functools import lru_cache

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    auth0_domain: str
    auth0_api_audience: str
    auth0_issuer: str
    auth0_algorithms: str

    class Config:
        env_file = ".env"

@lru_cache()
def get_settings():
    return Settings()

pydantic_settings.BaseSettingsを利用すると.envファイルから情報を読み込むことができるようになります。

次に、トークンをチェックする仕組みを実装します。

applications/utils.py
from typing import Optional # 👈 new imports

import jwt # 👈 new imports
from fastapi import Depends, HTTPException, status # 👈 new imports
from fastapi.security import SecurityScopes, HTTPAuthorizationCredentials, HTTPBearer # 👈 new imports

from application.config import get_settings # 👈 new imports

class UnauthorizedException(HTTPException):
    def __init__(self, detail: str, **kwargs):
        super().__init__(status.HTTP_403_FORBIDDEN, detail=detail)

class UnauthenticatedException(HTTPException):
    def __init__(self):
        super().__init__(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Requires authentication"
        )


class VerifyToken:
    """Does all the token verification using PyJWT"""

    def __init__(self):
        self.config = get_settings()

        # This gets the JWKS from a given URL and does processing so you can
        # use any of the keys available
        jwks_url = f'https://{self.config.auth0_domain}/.well-known/jwks.json'
        self.jwks_client = jwt.PyJWKClient(jwks_url)

        # 👇 new code
    async def verify(self,
                     security_scopes: SecurityScopes,
                     token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer())
                     ):
        if token is None:
            raise UnauthenticatedException

        # This gets the 'kid' from the passed token
        try:
            signing_key = self.jwks_client.get_signing_key_from_jwt(
                token.credentials
            ).key
        except jwt.exceptions.PyJWKClientError as error:
            raise UnauthorizedException(str(error))
        except jwt.exceptions.DecodeError as error:
            raise UnauthorizedException(str(error))

        try:
            payload = jwt.decode(
                token.credentials,
                signing_key,
                algorithms=self.config.auth0_algorithms,
                audience=self.config.auth0_api_audience,
                issuer=self.config.auth0_issuer,
            )
        except Exception as error:
            raise UnauthorizedException(str(error))
    
        return payload
        # 👆 new code

UnauthorizedExceptionUnauthenticatedExceptionの二つのカスタムExceptionを実装して、認証認可が失敗した時に発生させるエラーを実装しています。また、VerifyTokenクラスにて指定されたベアラートークンが有効なものであるかをチェックしています。トークンのチェックをするためにPyJWTfastapi.securityの各機能を用いてチェックを行っています。

そして、ここで実装したアプリケーションを利用して、プライベートアクセスAPIの設定を以下のように変更します。

private_router.py
from fastapi import APIRouter, Security
from fastapi.security import HTTPBearer  # 👈 new imports
from application.utils import VerifyToken # 👈 Import the new class


router = APIRouter()
auth = VerifyToken() # 👈 Get a new instance


@router.get("/api/private")
def private(auth_result: str = Security(auth.verify)):
    """A valid access token is required to access this route"""
    return auth_result

先ほどまでは設定されるベアラートークンは形式さえ合っていれば通っていましたが、今回はトークン情報を正式にチェックする仕組みが入っています。

実行してみる

それではAUth0の認証認可を連携させてみましょう。

まずはAuth0にアクセスしてアクセストークンを取得する必要があります。以下のコマンドを用いてアクセス情報を取得します。実行した結果、access_tokenに設定されているトークン情報を後ほど利用しますので控えておいてください。

curl -X 'POST' \
--url 'https://<YOUR DOMAIN HERE>/oauth/token' \
 --header 'content-type: application/x-www-form-urlencoded' \
 --data grant_type=client_credentials \
 --data 'client_id=<YOUR CLIENT ID HERE>' \
 --data client_secret=<YOUR CLIENT SECRET HERE> \
 --data audience=<YOUR AUDIENCE HERE>

# 結果
{"access_token":"...","expires_in":86400,"token_type":"Bearer"}

アクセストークンが取得できたら、以下のようにして先ほどは適当な文字列を指定していたアクセストークンを置き換えます。実行すると認証情報をデコードした内容が取得できます。

curl -X 'GET' --url http://127.0.0.1:8000/api/private --header 'Authorization: Bearer <ACCESS TOKEN>'

# 結果
{"iss":"https://....auth0.com/","sub":"...","aud":"...","iat":1753525039,"exp":1753611439,"gty":"client-credentials","azp":"..."}

ちなみに、JWTトークンの認証を実装した現時点で最初のように無意味なトークンを指定するともちろんエラーが出ます。

curl -X 'GET' --url http://127.0.0.1:8000/api/private --header 'Authorization: Bearer hogehogefugafuga'

# 結果
{"detail":"Not enough segments"}

まとめ

今回はAuth0の認証認可を利用してFastAPIのAPIをセキュアに実装する方法を学びました。Auth0は幅広く利用されている仕組みなので、誰かに公開して利用してもらうようなサービスを検討されている方はぜひAuth0を組み合わせて安全なAPI実装を試してみてはいかがでしょうか。

Discussion