delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

勉強のためにOpen ID ConnectのIDプロバイダー側をDjangoで実装する①

前提

  • Open ID ConnectのRelying Party側(以降RP)のフローをやってみる教材はAuth屋さんの本などがあるが、RP側の挙動をやってみるだけではまだまだOIDCの理解が深まっていないと感じる。
  • そこで年末年始ということもあり、DjangoでOIDCのIdP側をブログを書きながら実装してみることにした

authya.booth.pm

※ Djangoを使うのは以下の理由からで、深い意味はない

  • 一番馴染みがある
  • IdP側の実装ではDBが必要だが、 DjangoはSQLiteが内蔵されているので楽

Configurationエンドポイント

まずはディスカバリというIdPのエンドポイント一覧を定義するURLを実装する。

https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest

.well-known/openid-configurationというパスがよく使われる印象なので、本実装でもそのようにする。

Viewを実装する

まだIdPのエンドポイントを何も実装していないので、空のJSONを返すViewを実装する。

views.py

from django.http import JsonResponse
from django.views import View


class DiscoveryView(View):
    def get(self, request):
        return JsonResponse({})

url.pyに/.well-known/openid-configurationを定義する

urls.py

from django.urls import path

from . import views

app_name = "sampleapp"
urlpatterns = [
    path("`.well-known/openid-configuration/`", views.DiscoveryView.as_view(), name="discovery"),
]

これで.well-known/openid-configurationにアクセスすると空のJSONが返ってくるようになった。

$ curl -X GET "http://localhost:8000/sample/.well-known/openid-configuration/"
{}

認可エンドポイント

認可エンドポイントを実装していく

https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest

エンドポイントの追加

まずは先ほどのディスカバリに認可エンドポイントのURLを定義する。

views.py

class DiscoveryView(View):
    def get(self, request):
        return JsonResponse({
            "authorization_endpoint": "http://localhost:8000/sample/authorize/",
        })

これでディスカバリからエンドポイントを調べられるようになる。

$ curl -X GET "http://localhost:8000/sample/.well-known/openid-configuration/"
{"authorization_endpoint": "http://localhost:8000/sample/authorize/"}

Formの実装

Formにリクエストパラメータを定義していく。とりあえず以下を定義する。PKCEを実装する場合はcode_challengeとcode_challenge_methodが必要だが、最初はそこまではやらない。

パラメータ名 説明
response_type フローの種類を定義する。今回は認可コードフローなので固定でcodeという文字列を渡す
scope 要求するユーザー情報を半角スペースで区切って渡す。(例 email profile)
client_id RPに発行したクライアントID
state リクエスト時と状態をコールバック時に渡すために使うらしい。CSRF対策にも使える。※ セキュリティ対策の文脈で解説されることの方が多い印象
redirect_uri コールバック時のRPへの戻り先URL
nonce セキュリティ対策用のパラメータらしい。ランダム文字列でよい。詳しくは必要になったら理解する。

とりあえずこれらのパラメータをFormに定義する

forms.py

class AuthorizeForm(forms.Form):
  response_type = forms.CharField()
  scope = forms.CharField()
  client_id = forms.CharField()
  state = forms.CharField()
  redirect_uri = forms.CharField()
  nonce = forms.CharField()

response_typeはcode固定なのでバリデーションする

  def clean_response_type(self):
    response_type = self.cleaned_data["response_type"]
    if response_type != "code":
      raise forms.ValidationError("Invalid response_type")
    return response_type

scopeは半角スペース区切りの文字列の仕様だが、プログラムの中ではリストで扱いたいので変換する。

  def clean_scope(self):
    return  self.cleaned_data["scope"].split()

OIDCの仕様でopenidという文字列は必ず含む必要があるのでバリデーションする

  def clean_scope(self):
    scope = self.cleaned_data["scope"].split()
    if "openid" not in scope:
      raise forms.ValidationError("Invalid scope")
    return scope

Viewの実装

いったんパラメータを受け取ってprintデバッグでパラメータを出力するまでとする。

views.py

class AuthorizeView(View):
    def get(self, request):
        form = AuthorizeForm(request.GET)
        if not form.is_valid():
            return HttpResponseBadRequest()
        print("authorize params: ", form.cleaned_data)
        return HttpResponse("todo")

url.pyに/authorize/を定義する

urls.py

path("authorize/", views.AuthorizeView.as_view(), name="authorize"),

動作確認

以下のcurlを実行してみる。scopeの半角スペースは%20に置換しないとcurl: (3) URL rejected: Malformed input to a URL functionが発生するので注意する。

curl -X GET "http://localhost:8000/sample/authorize/?response_type=code&scope=openid%20email&client_id=test&state=abcd&redirect_uri=http://localhost/callback&nonce=efgh"

以下のprintデバッグ結果が出力できた。

authorize params:  {'response_type': 'code', 'scope': ['openid', 'email'], 'client_id': 'test', 'state': 'abcd', 'redirect_uri': 'http://localhost/callback', 'nonce': 'efgh'}`

今日はここまでとする。

参考にしているもの

Auth屋さんの本

各エンドポイントの仕様はAuth屋さんの本を authya.booth.pm

M3さんのテックブログの「フルスクラッチして理解するOpenID Connect 」シリーズ

実装する上で参考にさせて頂いています。