OAuthをPythonで動かして学ぶ
PythonでOAuthの認可コードフローを実装し、画面の移動からトークンの取得、APIの呼び出しまでを学びます。実際の通信とコードを照らし合わせ、学んだ内容をこの記事に記録します。
Pythonと基本的なWebの知識がある方を対象に、OAuthの処理を説明します。題材は利用者の許可を得て、別サービスの写真一覧を取得するアプリです。
次の順に確認します。
- 全体図で、ブラウザー経由の通信とサーバー間通信を把握する。
- デモを起動し、連携開始・許可・写真表示をコードと照らし合わせる。
- コード交換まで確認したところで、PKCEが必要な理由を整理する。
- 実装した処理のうち、OAuthの仕様とアプリ独自の設計を区別する。
このデモはローカル学習用です。OAuthを学び始めた立場から、同じように学ぶ方に向けて書いています。誤りや誤解を招く表現があれば、コメントいただけると幸いです。
この記事はChatGPTと共同で作成し、作者が内容を確認しています。
1. 作るものと通信の流れ
印刷アプリ、認可サーバー、写真APIの3つを動かします。利用者が許可すると、印刷アプリに認可コードが戻る構成です。印刷アプリはそのコードをアクセストークンに交換して、写真APIを呼びます。
2. デモを起動する
図の通信を実際に確認するため、実装ファイルを同じフォルダーに保存して起動します。以降のコードは、処理の要点を抜き出したものです。
コード全体は次のgithubに保存しています。実装コード(GitHub)
| ファイル | 役割 |
|---|---|
common.py |
URLなどの設定、PKCE計算、共通画面 |
client_app.py |
連携開始、コールバック、写真の取得 |
auth_server.py |
許可画面、コードとトークンの発行、トークン照会 |
resource_server.py |
利用証と権限を確認して写真一覧を返す |
uvがインストール済みのWindows環境で、プロジェクトのフォルダーから実行します。
uv sync --locked
同じフォルダーでターミナルを3つ開き、各ターミナルで次のコマンドを1つずつ実行します。終了するときは各ターミナルでCtrl+Cを押します。
uv run uvicorn auth_server:app --host 127.0.0.1 --port 8001 --no-access-log
uv run uvicorn resource_server:app --host 127.0.0.1 --port 8002 --no-access-log
uv run uvicorn client_app:app --host 127.0.0.1 --port 8000 --no-access-log
ブラウザーでhttp://localhost:8000を開きます。
3. 画面操作とコードを対応付ける
起動したデモで、連携の開始から写真の表示までを追います。前半はブラウザーの移動、後半は印刷アプリからのサーバー間通信です。画面操作とコードを照らし合わせながら、この切り替わりを確認します。
① 印刷アプリを開く
GET /を受けたhome関数が連携ボタンのあるHTMLを返します。まだ認可サーバーや写真APIへは通信しません。
「写真サービスと連携する」を押すと、ブラウザーがGET /connectを送ります。connectはstateとverifierを保存し、認可要求を組み立ててブラウザーを許可画面へ移動させます。
sessions[sid] = {"state": state, "verifier": verifier, "exp": time.time() + 300}
response = RedirectResponse(
AUTH_URL + "/authorize?" + urlencode(params),
status_code=302,
)
response.set_cookie("client_session", sid, httponly=True, samesite="lax", max_age=300)
return response
Cookieに保存するのはセッション番号sidだけで、stateとverifierはサーバー側に保持します。302の移動先URLに含めるのは計算済みのchallengeで、verifierそのものは送りません。
② 許可画面で選択する
authorize関数はアプリと戻り先が登録内容に合うか、要求の形式が正しいかを確認します。検証後、利用者の選択を待つ要求をpendingに保存し、許可画面を返します。
「許可する」を押すと、ブラウザーがPOST /approveを送ります。approve関数はCookieに対応する保存情報、csrfの照合値、有効期限を確認して、認可コードを発行します。
code = secrets.token_urlsafe(32)
codes[code] = {**item, "exp": time.time() + 60}
params = {"code": code, "state": item["state"]}
return RedirectResponse(REDIRECT_URI + "?" + urlencode(params), status_code=303)
上はコードの発行とリダイレクトの部分です。実装全体では、使用済みの待機情報と認可サーバー側のCookieも削除します。拒否した場合はコードを作らず、error=access_deniedとstateを返します。
③ 写真一覧を表示する
印刷アプリのcallback関数は、Cookieのセッション番号から保存情報を取り出し、stateを照合します。利用者が拒否した場合や照合に失敗した場合は終了し、許可された場合だけコード交換に進みます。
response = await client.post(
AUTH_URL + "/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code_verifier": item["verifier"],
},
)
response.raise_for_status()
access_token = response.json()["access_token"]
認可サーバーはコードの存在と期限を確認し、アプリ、戻り先、PKCEの値を照合します。検証に成功するとコードを削除し、アクセストークンを返します。
続いて、アクセストークンを写真APIに提示します。
response = await client.get(
API_URL + "/photos",
headers={"Authorization": f"Bearer {access_token}"},
)
写真APIはPOST /introspectでアクセストークンの有効性を発行元に照会します。調べるトークンは本文に、写真API自身の資格情報はBasic認証ヘッダーに入れます。この資格情報はAliceのパスワードとは別のものです。
写真APIは照会結果のactive、対象API、photos.read権限を確認し、有効なトークンに読み取り権限があれば写真一覧を返します。印刷アプリはその一覧をHTMLにしてブラウザーへ返します。
画面の通信履歴は、ブラウザーの開発者ツールでは見えないサーバー間通信を確認するため、このデモに追加した表示です。
4. コード交換をPKCEで保護する
コード交換では認可コードと一緒にcode_verifierを送りました。これは、コードの存在や有効期限の確認だけでは防げない、第三者によるコード交換への対策です。
認可コードが短時間で失効し、一度しか使えなくても、第三者が先に交換すると問題が起きます。client_idは公開された識別子なので、その値だけでは交換する相手を確認できません。
PKCEでは、連携開始時に作った秘密の値をコード交換時に検証します。
- 印刷アプリが毎回新しいcode_verifierを作り、保存する。
- SHA-256とBase64urlでcode_challengeを計算し、認可要求へ付ける。これがS256方式。
- 認可サーバーがchallengeをコードと結び付けて保存する。
- コード交換時に元のverifierを送り、認可サーバーが同じ計算で照合する。
コードだけを入手した第三者は、verifierを提示できないため交換に失敗します。verifierも漏れた場合や、予測できる固定値を使った場合まで防げるわけではありません。
stateは印刷アプリがコールバック時に連携操作との対応を確認する値です。PKCEでは認可サーバーがコード交換時にverifierを照合します。許可画面のPOSTでは、これらとは別のcsrf値を使います。PKCEは条件を満たせば認可コードの注入やCSRFの対策にも使えますが、すべてのCSRFを防ぐ機能ではありません。
参考:RFC 7636:PKCE、RFC 9700:OAuthのセキュリティ指針、OAuth2.0 PKCEとは 〜Stateとの違い〜(Qiita)。
5. 仕様と今回の設計を整理する
ここまでに確認した処理を、OAuthの通信規則と、このデモで選んだ実装方法に分けて整理します。
OAuthは連携に必要な通信を定めますが、画面や保存先、URLのパス名まで固定しているわけではありません。
| 対象 | 仕様に基づく部分 | 今回の設計 |
|---|---|---|
| 認可要求 | response_type=code、client_id、redirect_uri、scopeなど | パス/authorize、アプリ名print-app、権限名photos.read
|
| 連携開始と許可画面 | 認可要求を用意し、利用者の選択に応じた結果を返す | GET /connect、POST /approve、ボタンの文言、decisionとcsrf |
| 戻り先 | 登録値の検証。今回の方式ではcodeとstateをURLで返す | /callback、303による移動 |
| コード交換 | トークンエンドポイントへのPOST、コードの検証と一度限りの使用 | パス/token、辞書codesでの管理、有効期限60秒 |
| PKCE | RFC 7636の値の生成・送信・照合 | challenge関数とsessions辞書 |
| APIの呼び出し | RFC 6750のBearerヘッダー | GET /photos、固定の写真データ |
| トークン照会 | RFC 7662を採用した場合のPOSTとactiveなどの応答 | 毎回/introspectへ照会し、写真APIをBasic認証で確認 |
| 保存と期限 | コードを短時間で失効させる | Pythonの辞書、トークン300秒、連携操作300秒 |
認可サーバーにはリダイレクト先を事前登録します。要求のredirect_uriは「今回はどの登録先へ戻るか」の指定です。今回は1つですが、一般的な流れを学ぶため毎回送り、登録値と比較します。認可要求に指定した場合はコード交換でも同じ値を送ります。
APIを呼ぶたびにトークンを照会することは、OAuthの認可コードフローで必須ではありません。今回はランダム文字列のトークンを使うため、発行元に有効性を確認する構成を選びました。
まとめ
実際に実装して、動かしてみることでちょっとだけわかった気になれました。次はEntraIDをIdPとして使用してOAuthを構築する方法を調べて実装したいと思います。


