FastAPIでJWTをHttpOnly Cookieに保存する
FastAPIのチュートリアルを行った人は、Json Web Token(JWT)を発行するエンドポイントの実装はすでに行ったかと思います。ただし、そのエンドポイントは、JWTの文字列を返すのみです。
しかし、JWTの保存場所として、HttpOnly Cookieを利用しているパターンが多くあるように感じます。HttpOnly Cookieに保存するためには、FastAPIで設定する必要があります。
そこで、本記事では、FastAPIで以下の実装を行います。
- JWTをHttpOnly Cookieに保存させるエンドポイント
- JWTをHttpOnly Cookieから削除するエンドポイント
- JWTをHttpOnly Cookieから取得する関数
前提条件
本記事は、FastAPIチュートリアルのパスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearerまでを理解していることを前提としています。
本記事で使用したpythonとFastAPIのバージョンは以下の通りです。
python = 3.12
fastapi = 0.111.0
HttpOnly Cookieに保存
Cookieへの保存の仕方は、公式サイトにもあります。
Cookieへの保存は、公式サイトにある通りで、パスオペレーション関数の引数にResponse
を追加し、その変数のset_cookie
メソッドを使うだけです。HttpOnly Cookieに保存する場合は、httponly=True
としましょう。レスポンスボディは、設定しなくてもよいので、パスオペレーション関数の返値は、None
でも良いです。
HttpOnly Cookieへ保存させる例
from fastapi import FastAPI, Response
app = FastAPI()
@app.post("/cookie-and-object/")
def create_cookie(response: Response):
response.set_cookie(
key="fakesession",
value="fake-cookie-session-value",
httponly=True, # HttpOnly Cookieに保存
)
return None
それでは、JWTをHttpOnly Cookieに保存させるエンドポイントの実装例を紹介します。
from fastapi import FastAPI, Response
from fastapi.security import OAuth2PasswordRequestForm
app = FastAPI()
@app.post("/login", response_model=None)
def login(
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
):
# ユーザー認証
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=400, detail="ユーザー名かパスワードが異なります。"
)
# トークンの発行
payload = schemas.TokenPayload(sub=user.username)
# note: Cookieに有効期限を付けるので、有効期限も返してもらう
access_token, expire = create_access_token(payload)
# Cookieの設定
response.set_cookie(
key="access_token",
value=f"Bearer {access_token}",
httponly=True, # HttpOnly
expires=expire, # 有効期限の設定
)
return None
Cookieを削除
Cookieから値を削除する方法も同じように、パスオペレーション関数の引数にResponse
を追加し、その変数のdelete_cookie
メソッドを使うだけです。引数には、cookieのkeyを指定します。cookieがHttpOnlyであるかどうかは、ここでは関係ありません。
Cookieから削除させる例
from fastapi import FastAPI, Response
app = FastAPI()
@app.post("/delete-cookie/")
def delete_cookie(response: Response):
response.delete_cookie("fakesession")
return None
それでは、JWTをHttpOnly Cookieから削除するエンドポイントの実装例です。key="access_token"
にJWTを保存した場合の例です。
from fastapi import FastAPI, Response
app = FastAPI()
@app.post("/logout", response_model=None)
def logout(response: Response):
response.delete_cookie("access_token")
return None
Cookieから取得
Cookieから値を取得する方法は、公式サイトにもあります。
公式サイトの案内のように、パスオペレーション関数の引数にkey_name: str = Cookie()
を追加するだけです。引数の名前は、Cookieのkeyに一致するものを設定しなければなりません。
Cookieから取得する例
from fastapi import Cookie, FastAPI
app = FastAPI()
@app.get("/items/")
def read_items(
ads_id: str | None = Cookie(default=None), # key=ads_idの値を取得する
):
return {"ads_id": ads_id}
JWTをHttpOnly Cookieから取得する関数の実装例を紹介します。ここでは、アクセストークンが取得できなかった場合、401エラーとします。
from fastapi import Cookie, HTTPException
def get_token_from_cookie(access_token: str | None = Cookie(default=None)):
if(access_token is None):
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return access_token
エンドポイントでJWTを使用する場合、Depends
を使って以下のように加えましょう。
from fastapi import FastAPI, Depends
app = FastAPI()
@app.get("/hoge/")
def hoge(jwt: str = Depends(get_token_from_cookie)):
pass
ここで、FastAPIのチュートリアルであったget_current_user
をCookieからJWTを取得できるように修正します。oauth2_scheme
は、ヘッダーからJWTを取得しているので、これをget_token_from_cookie
に変更しましょう。
def get_current_user(
- token: str = Depends(oauth2_scheme)
+ token: str = Depends(get_token_from_cookie)
):
pass
(おまけ)Swagger UIの挙動を戻す
最後におまけとして、Swagger UIのログイン・ログアウトの挙動を戻す方法を紹介します。
CookieにJWTを保存する場合、FastAPIが自動生成するドキュメントSwagger UIでのログイン、ログアウトができなくなります。それは、Swagger UIでの認証がAuthorizationヘッダーを使うものを想定してあるからです。
そのため、トークンをレスポンスとして返すエンドポイントを実装し、AuthorizationヘッダーからもJWTを取得できるようにする必要があります。
まず、トークンをレスポンスとして返すエンドポイントですが、FastAPIのチュートリアルにある通りの実装です。
トークンをレスポンスとして返すエンドポイント
@app.post("/token")
def login(
form_data: OAuth2PasswordRequestForm = Depends()
):
# ユーザー認証
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=400, detail="ユーザー名かパスワードが異なります。"
)
# トークン発行
payload = schemas.TokenPayload(sub=user.username)
access_token = create_access_token(payload)
return {"access_token": access_token, "token_type": "bearer"}
次にトークンを取得する方法についてですが、AuthorizationヘッダーとCookieのどちらからもJWTを取得できるようにします。この実装は、FastAPI公式のIssuesにあったCookie based JWT tokensを参考にしています。
公式のチュートリアルでは、oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
としていましたが、このOAuth2PasswordBearer
クラスをAuthorizationヘッダーからもCookieからもアクセストークンを受け取るように改造します。
AuthorizationヘッダーまたはCookieからJWTを受け取るクラス
from typing import Optional
from fastapi import Request, HTTPException
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
class OAuth2PasswordBearerWithCookie(OAuth2):
def __init__(
self,
tokenUrl: str,
scheme_name: str = None,
scopes: dict = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.headers.get("Authorization")
if not authorization:
authorization: str = request.cookies.get("access_token")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param
このクラスの使い方ですが、OAuth2PasswordBearer
と変わりません。
# tokenUrlにトークンをレスポンスとして返すエンドポイントのURLを設定
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="token")
# Dependsを使って引数にインスタンスを追加
def get_current_user(
token: str = Depends(oauth2_scheme)
):
pass
まとめ
本記事では、FastAPIで以下の実装例を紹介しました。
- JWTをHttpOnly Cookieに保存させるエンドポイント
- JWTをHttpOnly Cookieから削除するエンドポイント
- JWTをHttpOnly Cookieから取得する関数
FastAPIのチュートリアルは、充実していますが、Cookieへの保存・削除についてはあまり紹介されていなかったので、記事にさせていただきました。
JWTをCookieに保存するとフロントエンド(javascript)では、axios
のwithCredentials
をtrue
に設定するだけで済みますし、なにかと便利になることも多いのではないでしょうか。ぜひこの方法を使ってみてください。
Discussion