1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OAuthって何? Pythonで小さく作って認可コードの流れを理解する

1
Posted at

OAuthをPythonで動かして学ぶ

PythonでOAuthの認可コードフローを実装し、画面の移動からトークンの取得、APIの呼び出しまでを学びます。実際の通信とコードを照らし合わせ、学んだ内容をこの記事に記録します。

Pythonと基本的なWebの知識がある方を対象に、OAuthの処理を説明します。題材は利用者の許可を得て、別サービスの写真一覧を取得するアプリです。

次の順に確認します。

  1. 全体図で、ブラウザー経由の通信とサーバー間通信を把握する。
  2. デモを起動し、連携開始・許可・写真表示をコードと照らし合わせる。
  3. コード交換まで確認したところで、PKCEが必要な理由を整理する。
  4. 実装した処理のうち、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へは通信しません。

image.png

「写真サービスと連携する」を押すと、ブラウザーが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に保存し、許可画面を返します。

image.png

「許可する」を押すと、ブラウザーが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を返します。

③ 写真一覧を表示する

image.png

印刷アプリの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では、連携開始時に作った秘密の値をコード交換時に検証します。

  1. 印刷アプリが毎回新しいcode_verifierを作り、保存する。
  2. SHA-256とBase64urlでcode_challengeを計算し、認可要求へ付ける。これがS256方式。
  3. 認可サーバーがchallengeをコードと結び付けて保存する。
  4. コード交換時に元の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を構築する方法を調べて実装したいと思います。

参考資料

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?