delhi09の勉強日記

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

Rubyのハッシュとキーワード引数の省略記法

Ruby3.1からハッシュとキーワード引数の省略記法が使えます。

仕事でコードレビューをしていて「これは何だ?」と思ってしまったので確認しておきたいと思います。

www.ruby-lang.org

ハッシュの場合

事前にc = 3のようにローカル変数を定義している場合、{ c: }と書くだけで{c: 3}と同じ意味になります。

mapping1 = {
  a: 1,
  b: 2
}
pp mapping1 # {a: 1, b: 2}

c = 3
mapping2 = {
  c:,
  d: 4
}
pp mapping2 # {c: 3, d: 4}

キーワード引数の場合

事前にname = "田中"のようにキーワード引数と同じ名前のローカル変数を定義している場合、hello(name:)と書くだけでhello(name: name)と同じ意味になります。

def hello(name:)
  puts "こんにちは#{name}さん!"
end

hello(name: "山田") # こんにちは山田さん!

name = "田中"
hello(name:) # こんにちは田中さん!

参考

qiita.com

WEB+DB PRESS Vol.127』を読むとこの省略記法が導入された背景もわかって理解が深まります。

gihyo.jp

RubyのStringクラスに絵文字変換メソッドを追加してみる(オープンクラスの勉強)

Rubyの特徴であるオープンクラスの勉強のため、Stringクラスに絵文字変換メソッドを追加してみます。

例えば、"カレー".to_emojiを実行すると🍛がreturnされるイメージです。

実装

以下のように簡単に実装できました。

class String
  def to_emoji
    case self
    when "カレー"
      "🍛"
    when "ラーメン"
      "🍜"
    when "寿司"
      "🍣"
    else
      self
    end
  end
end

Stringインスタンス自身を参照するためにはselfが使えます。

docs.ruby-lang.org

実行結果

# ※ class定義に続けて実行

puts "カレー".to_emoji # 🍛
puts "ラーメン".to_emoji # 🍜
puts "寿司".to_emoji # 🍣
puts "牛丼".to_emoji # 牛丼 ※ 対応する絵文字が定義されていないため、「牛丼」がそのまま返される
puts "curry".upcase # CURRY ※ 既存のRubyのStringクラスの挙動が破壊されていないことの確認

Rubyの「=で終わるメソッド」が代入風に書けるのは言語仕様なのか?

課題

Rubyでは、フィールド名+=というメソッド名を定義することで、インスタンス変数への代入のような表現ができます。

以下のサンプルコードでいうと、def name=(name)がそれにあたります。

class Person
  def initialize(name)
    @name = name
  end

  def name=(name)
    @name = name
  end

  def introduce
    puts "私の名前は#{@name}です"
  end
end

p = Person.new("山田")
p.introduce # 私の名前は山田です

p.name = "田中"
p.introduce # 私の名前は田中です

このような書き方が可能なのは、次のどちらの理由によるものなのか気になったので調べてみました。

  1. Rubyではメソッド名に=が使えるため、これは単なるRuby文化的な慣習にすぎない
  2. Rubyの言語仕様としてメソッドの末尾が=の場合が特別にサポートされている

結論

答えは2.でした。Rubyのパーサーには、末尾が=で終わるメソッド専用の処理ロジックが組み込まれていました。

推論

最終的にはRubyのパーサーのコードを確認して裏付けを取りましたが、挙動の観察だけでも2.の可能性が高いと推測できます。

p.name=("田中")の場合

メソッド名がname=なので、当然ながら次のようにも書けます。

# 省略
p.name=("田中")
p.introduce # 私の名前は田中です

p.name= "田中"の場合

Rubyではメソッド呼び出しのかっこを省略できるため、次のように書いても動作します。

# 省略
p.name= "田中"
p.introduce # 私の名前は田中です

ここまでは、1.と2.のどちらの仮説でも説明がつきます。

p.name = "田中"の場合

しかし、p.name=の間にスペースが入るこの書き方も動作します。

# 省略
p.name = "田中"
p.introduce # 私の名前は田中です

つまり、Rubyp.name =p.name=として解釈していることになります。

Rubyには「メソッド名の途中にスペースが入っても自動で補完する」といった仕様は存在しないため、この挙動は通常のメソッド呼び出しルールでは説明できません。

たとえば、name=メソッドの末尾を?に書き換えて同様のことを試してみると、syntax error, unexpected tIDENTIFIER, expecting ':'というエラーになります。

class Person
  # 省略
  def name?(name)
    @name = name
  end
  # 省略
end
# 省略
p.name ? "田中"
p.introduce # 私の名前は田中です

このことから、Rubyには末尾が=で終わるメソッドには特別な構文ルールが存在すると推測できます。

裏付け

裏を取るためにChatGPTにナビゲーションしてもらいながら、Rubyのパーサーのソースコードを読んでみました。

ChatGPTによると、Ruby本体のリポジトリに同封されているパーサーよりもRuby3.4からデフォルトになったPrismパーサーのソースコードを読んだほうがいいそうです。

github.com

参考記事

gihyo.jp gihyo.jp

src/prism.c内のparse_write関数を見ると、以下のような実装が確認できます。

parse_write(pm_parser_t *parser, pm_node_t *target, pm_token_t *operator, pm_node_t *value) {
    // 省略
    switch (PM_NODE_TYPE(target)) {
        // 省略
        case PM_CALL_NODE: {
                // 省略
                if (char_is_identifier_start(parser, call->message_loc.start, parser->end - call->message_loc.start)) {
                    // When we get here, we have a method call, because it was
                    // previously marked as a method call but now we have an =. This
                    // looks like:
                    //
                    //     foo.bar = 1
                    //
                    // When it was parsed in the prefix position, foo.bar was seen as a
                    // method call with no arguments. Now we have an =, so we know it's
                    // a method call with an argument. In this case we will create the
                    // arguments node, parse the argument, and add it to the list.
                    pm_arguments_node_t *arguments = pm_arguments_node_create(parser);
                    call->arguments = arguments;

                    pm_arguments_node_arguments_append(arguments, value);
                    call->base.location.end = arguments->base.location.end;

                    parse_write_name(parser, &call->name);
                    pm_node_flag_set((pm_node_t *) call, PM_CALL_NODE_FLAGS_ATTRIBUTE_WRITE | pm_implicit_array_write_flags(value, PM_CALL_NODE_FLAGS_IMPLICIT_ARRAY));

                    return (pm_node_t *) call;
                }
            }
            // 省略
        }
       // 省略
    }
}

私はパーサーのコードを読み慣れてない & C言語に疎いのでロジックの詳細は分かりませんが、コードコメントからこのブロックが末尾が=で終わるメソッド専用のロジックであることが伺えます。

さらに、parse_writePM_CALL_NODEのブロックで呼び出しているparse_write_nameを読むと、メソッド名の末尾に=を追加する処理をやっていそうです。

parse_write_name(pm_parser_t *parser, pm_constant_id_t *name_field) {
    // The method name needs to change. If we previously had
    // foo, we now need foo=. In this case we'll allocate a new
    // owned string, copy the previous method name in, and
    // append an =.
    pm_constant_t *constant = pm_constant_pool_id_to_constant(&parser->constant_pool, *name_field);
    size_t length = constant->length;
    uint8_t *name = xcalloc(length + 1, sizeof(uint8_t));
    if (name == NULL) return;

    memcpy(name, constant->start, length);
    name[length] = '=';

    // Now switch the name to the new string.
    // This silences clang analyzer warning about leak of memory pointed by `name`.
    // NOLINTNEXTLINE(clang-analyzer-*)
    *name_field = pm_constant_pool_insert_owned(&parser->constant_pool, name, length + 1);
}

以上より、Rubyのパーサーは「foo.bar = 1のような式を見つけたら、bar=というメソッド呼び出しとして扱う」という専用処理を持っていることが確認できました。

django-auditlogで特定のユーザー種別の場合にログを抑制する方法

概要

django-auditlogというjazzbandの管轄のDjangoのモデル操作の監査ログを作成してくれるライブラリがあります。

github.com

基本的な使い方は以下の記事でryu22eさんが紹介してくださっているので割愛します。

qiita.com

モデルを監視してCRUD操作を検知する思想なので、モデル単位でのログ抑制は設定で可能です。

ただ今回はユーザー種別の場合にログを抑制したいというニーズがあり、やり方が分からなかったので技術検証してみます。

ChatGPTに聞いてみた

前提としてググってもdjango-auditlogの発展的な使い方に関する情報はほとんど見つかりませんでした。

目ぼしいものは8年前のものですが以下のStack Overflowの記事くらいでした。この記事も前提となるDjangoのバージョンが1.8かつdjango-auditlogも2023年に3系にメジャーバージョンアップしているのでそのまま使える情報ではなさそうです。

stackoverflow.com

ChatGPTに聞いてみたところ、以下のような回答でした。

ChatGPTの回答

①カスタム Middleware を定義する

your_app/middleware.py

from auditlog.middleware import AuditlogMiddleware

class CustomAuditlogMiddleware(AuditlogMiddleware):
    def get_user(self, request):
        user = super().get_user(request)

        # 例: User モデルに user_type フィールドがある場合
        if getattr(user, 'user_type', None) in ['system', 'batch']:
            return None  # ログに記録しないようにする

        return user

settings.pyで Middleware を差し替える

MIDDLEWARE = [
    # 'auditlog.middleware.AuditlogMiddleware',  ← 削除
    'your_app.middleware.CustomAuditlogMiddleware',  # ← 追加
    # その他のミドルウェア
]

AuditlogMiddleware_get_actorのオーバーライドを試す

ChatGPTの回答の裏取りで最新のAuditlogMiddlewareソースコードをみてみたところ、get_userというメソッドはありませんでしたが、恐らく_get_actorのことを指しているのだろうと読み替えました。

github.com

以下のように_get_actorをオーバーライドして、元の実装に追加でuser.is_staff = Trueの場合はNoneを返すようにしてみました。

from auditlog.middleware import AuditlogMiddleware
from django.contrib.auth import get_user_model

class CustomAuditlogMiddleware(AuditlogMiddleware):
    
    @staticmethod
    def _get_actor(request):
        user = getattr(request, "user", None)
        if not isinstance(user, get_user_model()):
            return None
        if not user.is_authenticated:
            return None
        if user.is_staff:
            return None
        return None

結果

USERsystemとなっただけでログは作成されてしまい、期待通りの挙動にはなりませんでした。

_get_actorNoneを返した場合、USERsystemになるだけでログ自体は作成される仕様のようです。

AuditlogMiddleware__call__のオーバーライドを試す①

AuditlogMiddlewareをオーバーライドする方針はそのままで、__call__の方を以下のようにオーバーライドしてみました。

from auditlog.middleware import AuditlogMiddleware
from auditlog.cid import set_cid
from auditlog.context import set_actor

class CustomAuditlogMiddleware(AuditlogMiddleware):
    
    def __call__(self, request):
        remote_addr = self._get_remote_addr(request)
        remote_port = self._get_remote_port(request)
        user = self._get_actor(request)

        if user and user.is_staff: # 追加: ユーザーがスタッフの場合は監査ログを抑制する
            return self.get_response(request)

        set_cid(request)

        with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
            return self.get_response(request)

結果

これでもUSERsystemとなっただけでログは作成されてしまい、期待通りの挙動にはなりませんでした。

set_actorはあくまでログに操作したユーザーの情報を付与するだけの役割であって、with set_actorを実行しないようにするだけではログは抑制できないようです。

https://django-auditlog.readthedocs.io/en/latest/usage.html#set-actor

AuditlogMiddleware__call__のオーバーライドを試す②

公式ドキュメントをみていたらset_actorと同様にwithブロックで使えるdisable_auditlogという関数を発見しました。

https://django-auditlog.readthedocs.io/en/latest/usage.html#disable-auditlog

__call__の中でこれを実行するようにしてみます。

from auditlog.middleware import AuditlogMiddleware
from auditlog.cid import set_cid
from auditlog.context import set_actor, disable_auditlog # disable_auditlogのインポートを追加

class CustomAuditlogMiddleware(AuditlogMiddleware):
    
    def __call__(self, request):
        remote_addr = self._get_remote_addr(request)
        remote_port = self._get_remote_port(request)
        user = self._get_actor(request)

        if user and user.is_staff: # ユーザーがスタッフの場合は監査ログを抑制する
            with disable_auditlog():
                return self.get_response(request)

        set_cid(request)

        with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
            return self.get_response(request)

結果

この方法でuser.is_staff = Trueの場合にログを抑制することに成功しました。

Deep Researchにスギ花粉が飛ばない旅行先を探してもらった

課題

スギ花粉シーズンに花粉が飛ばない旅行先を調べると小笠原諸島、沖縄、北海道など簡単には行けないところばかり出てくる。

例外として草津が紹介されている記事を見かけたので理由を調べたところ、スギ花粉は標高1000m以上ではあまり飛ばないらしい。草津は標高1200m以上なのでスギ花粉が飛ばないという理由だった。

ただ、標高1000m以上の旅行先というのは意外と他に探すのが難しい。例えばパッと思いつく標高が高そうな温泉街を調べたところ

とのことだった。

ちょうどDeep Researchが話題になっているので、草津以外の標高の観点で花粉の避粉地としておすすめの旅行先を探してもらうのがお題としていいのでは?と考えた。

やったこと

まずは以下を入力してみた。

スギ花粉シーズンに、標高1,200mの草津のように標高1000m以上で花粉が飛ばない旅行先を調べてください。花粉シーズンの旅行先を調べると、草津以外だと北海道や離島や国外を紹介する記事ばかりで、「そこまではいけないな」と困っています。草津以外にもスギ花粉が飛ばないくらい標高が高い旅行先はあるのではないかと思っています。とはいえ、標高が高そうなイメージの温泉地帯を調べると、

- 箱根: 約100m
- 下呂温泉: 300m
- 野沢温泉: 600m

と、意外と1000m以上という条件を満たす旅行先を見つけるのも難しいなと感じています。

すると、以下のようにより詳細な要件を求められた。

草津以外で標高1,000m以上に位置し、スギ花粉が少ない旅行先をお探しですね。

より的確な候補を見つけるために、以下の点を教えてください。

1.エリアの希望:関東圏、甲信越、関西など、特定の地域の希望はありますか?
2.目的:温泉地を中心に探しますか?それとも景観や観光を楽しめる場所ならOKですか?
3.宿泊施設の有無:宿泊できる施設がある場所がよいですか?
4.アクセス:車が必要な場所でも大丈夫ですか?公共交通機関で行けるほうがよいですか?
この情報があれば、より適した候補地をリストアップできます!

以下のように回答した。

1. 関東を中心に青森から近畿地方までくらいならOKです。関東に近いほどうれしいです。
2. 温泉地を中心に探します
3. 宿泊したいです
4. 公共交通機関で行けるほうがよいです

体感10分〜20分くらい調べたのち、リストアップしてくれた旅先は以下だった。

たしかに裏をとってみると、全て標高1000m以上という条件を満たした温泉街であり、最後の青森を除くと関東からアクセスしやすい場所を選んでくれている。

「他にもありますか?」と聞くと、追加で以下を挙げてくれた。

全部長野県だったので「長野県以外もありますか?」と聞くと以下を挙げてくれた。

たしかに全部実際に裏をとってみると実在する温泉街で標高も1000m以上だった。すごい。

Deep Researchは一定期間に10回までしか使えないという制約があるようだが、初回の調査結果に対する追加質問の際は回数は減らないっぽい。

django-allauthのSign In画面をスキップする方法

課題

django-allauthのデフォルトでは、未ログイン状態でログイン必須な画面(=login_requiredLoginRequiredMixinが使われている画面)にアクセスすると、以下のSign In画面にリダイレクトされる。

この画面で「Keycloak」を押すと続けて以下の画面に遷移する。

「Continue」を押すとようやくKeycloakのログイン画面に辿り着ける。

間に意味のない画面が2つ挟まるとユーザー体験が悪いので、特にSSOのIdPがKeycloakのみで選択の余地がない場合などは、未ログイン状態でログイン必須な画面にアクセスした場合に、Sign In画面を遷移せずに直接IdPのログイン画面に遷移してほしい。

実現方法を調べた。

Sign In画面の正体

まずは1つ目のSign In画面の正体を調べる。

Sign In画面のURLは/accounts/login/であるが、これはDjangosettings.LOGIN_URLのデフォルト値である。

https://docs.djangoproject.com/ja/5.1/ref/settings/#login-url

django-allauthもLOGIN_URLに設定されたURLにリダイレクトさせているだけである。従って、例えばsettings.pyLOGIN_URL=http://exmple.com/と設定すると、以下のようにhttp://exmple.comにリダイレクトされる。

django-allauthではリクエストパスが/accounts/login/の場合に、allauth.account.views.LoginViewが呼ばれる。

https://github.com/pennersr/django-allauth/blob/main/allauth/account/views.py

LoginViewでは以下のテンプレートが使われている。テンプレートの中身を確認すると、確かにIf you have not created an account yet,...という文言が入っている。 https://github.com/pennersr/django-allauth/blob/main/allauth/templates/account/login.html

django-allauthのデフォルトのSign In画面の正体は分かった。

Sign In Via Keycloak 」画面の正体

次に2つ目の「Sign In Via Keycloak 」という画面の正体を調べる。

これは正攻法で調べられなかったので、You are about to sign in using a third-party account fromという文言でソースコードgrepしてみた。

allauth/templates/socialaccount/login.htmlのものであることが分かった。 https://github.com/pennersr/django-allauth/blob/main/allauth/templates/socialaccount/login.html

→ このテンプレートを使っているViewを調べたところ、直接Viewには設定されておらず、allauth.socialaccount.providers.base.utils.respond_to_login_on_getで使われていた。

https://github.com/pennersr/django-allauth/blob/main/allauth/socialaccount/providers/base/utils.py

respond_to_login_on_getを使っている箇所をgrepしたら、allauth.socialaccount.providers.base.BaseLoginViewdispatchで使われていた。

https://github.com/pennersr/django-allauth/blob/main/allauth/socialaccount/providers/base/views.py

詳細は割愛するが、BaseLoginViewを継承しているViewを調べたところ、get_providerメソッドをオーバーライドしているだけであり、getpostdispatchなどの処理のメインストーリームになる処理はオーバーライドされていなかった。従って、処理の本体はBaseLoginViewdispatchである。

Sign In Via Keycloak 」画面のHTMLを読むと、「Continue」を押した際に同一URLに対してパラメータなしでPOSTする実装になっている。

ここでBaseLoginViewdispatchの実装を見てみると、POSTの場合は、respond_to_login_on_getNoneを返すから、return provider.redirect_from_request(request)が実行されると考えられる。

class BaseLoginView(View):
    provider_id: str  # Set in subclasses

    def dispatch(self, request, *args, **kwargs):
        if allauth_settings.HEADLESS_ONLY:
            raise Http404
        provider = self.get_provider()
        resp = respond_to_login_on_get(request, provider)
        if resp: # ←POSTの場合、`resp`がNoneになるのでこのifブロックには入ってこない
            return resp
        return provider.redirect_from_request(request) # POSTの場合こっちが実行される

以上より、Viewの中でreturn provider.redirect_from_request(request)を実行すると、「Continue」ボタンを押した時と同じようにKeycloakのログイン画面にリダイレクトできそうということが分かった。

Sign In画面のスキップの実現方法

これまでの調査より、以下の項目を実装すると、未ログイン状態でログイン必須な画面にアクセスした際に、直接Keycloakのログイン画面にリダイレクトできそうである。

  1. settings.LOGIN_URLを独自のURLにする
  2. 1.のURLに紐付けたViewのGET処理の中で、以下の処理を行う。
    • Keycloak用のProviderのインスタンスを取得する。
    • return provider.redirect_from_request(request)を実行する

仮説を検証してみる。

settings.LOGIN_URLの定義

settings.pyLOGIN_URL = "app:auth_request"と定義する。app:auth_requestはこれから実装する。

settings.py

# ...省略
LOGIN_URL = "app:auth_request"

Viewの定義

以下の空のViewを定義する

views.py

class AuthRequestView(View):
    def get(self, request):
        pass

urls.pyの定義

定義したViewをリクエストパスに紐付ける

urls.py

# ...省略
app_name = "app"
urlpatterns = [
    # ...省略
    path("auth_request/", views.AuthRequestView.as_view(), name="auth_request"),
]

Providerインスタンスの取得方法の調査

Viewの中で、Keycloak用のProviderのインスタンスを取得したいが、やり方がわからないのでdjango-allauthのソースコードから調べる。ちょうど先に調べたBaseLoginViewの中でやっている。

https://github.com/pennersr/django-allauth/blob/main/allauth/socialaccount/providers/base/views.py

# ...省略
from allauth.socialaccount.adapter import get_adapter
# ...省略

class BaseLoginView(View):
    provider_id: str  # Set in subclasses

    # ...省略
    def get_provider(self):
        provider = get_adapter().get_provider(self.request, self.provider_id)
        return provider

provider_idが分かればproviderを取得できそうである。

→ Keycloakをdjango-allauthに設定する際にsettings.pyに以下のような定義をしている。provider_idというフィールドにkeycloakという文字列を渡しているので、これのことだと考えられる。

settings.py

# ...省略
SOCIALACCOUNT_PROVIDERS = {
    "openid_connect": {
        "APPS": [
            {
                "provider_id": "keycloak", # これ
                "name": "Keycloak",
                "client_id": "testclient",
                "secret": "********",
                "settings": {
                    "server_url": "http://proxy:80/realms/master/.well-known/openid-configuration",
                },
            }
        ]
    }
}
# ...省略

AuthRequestViewの実装

providerインスタンスの取得方法がわかったので、AuthRequestViewを実装する。

# ...省略
from allauth.socialaccount.adapter import get_adapter

# ...省略 
class AuthRequestView(View):
    def get(self, request):
        provider = get_adapter().get_provider(request, "keycloak")
        return provider.redirect_from_request(request)

結果

Keycloakのログイン画面に遷移できた。

URLのパラメータもOIDCの仕様に準拠したものが付与されている。

http://proxy/realms/master/protocol/openid-connect/auth?client_id=testclient&redirect_uri=http%3A%2F%2Flocalhost%3A18000%2Faccounts%2Foidc%2Fkeycloak%2Flogin%2Fcallback%2F&scope=email+openid+profile&response_type=code&state=E2fs6e3NlDZop6oR

ID/パスワードを入力するとちゃんとコールバックされて、Djangoアプリ側でのログインも成功していることを確認できた。

KeycloakのIDトークン・アクセストークンにユーザーのカスタム属性を追加する

概要

KeycloakのIDトークン・アクセストークンにユーザーのカスタム属性を追加する方法を調べた。

ユーザーエンティティへの属性の追加

まずはKeycloak上のユーザーエンティティに属性を追加する方法を調べる。今回はユーザーのブログのURL(=blogUrl)という属性を追加してみる。

Keycloakの管理画面のメニューから「Realm settings -> User profile」でユーザーの属性を確認できる。デフォルトでは以下の4つの属性が登録されている。

  • username
  • email
  • firstName
  • lastName

「Create attribute」から属性を追加する。1回開いてみたら

  • Display name
  • Attribute group
  • Permission
  • Validations

あたりを設定をどのようにしたらいいか分からなかったので、参考にするためにfirstNameの設定を確認してみた。すると以下のように設定されていた。

真似して入力してみる。「Validations」はいったん空とする。

以下のようにblogUrlを登録できた。

実際にユーザーを作成してblogUrlを追加してみる。メニューの「Users > Add user」から登録する。

ここまででユーザーエンティティへの属性の追加方法は検証できた。

IDトークン・アクセストークンを取得する事前準備(検証方法に興味がない人は飛ばしてください)

IDトークン・アクセストークンを取得するためには、OIDCのフローをトークンリクエストまで実行する必要があるので、その準備をする。

新規追加したユーザーにデフォルトではパスワードが設定されていない。OIDCのフローを実行ためには設定が必要。「Users > Credentials > Set password」から設定する。

クライアント(Relying Party)も作成する必要がある。メニューの「Clients > Create client」から作成する。

「Client authentication」を有効にする。

「Valid redirect URIs」はOIDCのフローに必要なのでhttp://localhost/callback/登録する。

「Clients > Credentials」からClient Secretを確認して控えておく。

IDトークン・アクセストークンを取得する(初期設定ではブログのURLが含まれないことの確認なので結論だけ知りたい人は読み飛ばしてください)

IDトークン・アクセストークンを取得するためにOIDCのフローをトークンリクエストまで実行する。

まずはブラウザに以下のURLを入力する。

http://localhost:18080/realms/master/protocol/openid-connect/auth?response_type=code&scope=openid%20profile&client_id=testclient&redirect_uri=http://localhost/callback/

ログインに成功すると以下のURLにリダイレクトされるので、クエリストリングのcodeをコピる。

以下のcurlコマンドを実行する。

curl -X POST -d "grant_type=authorization_code" -d "code={コピったコード}" -d "redirect_uri=http://localhost/callback/" -d "client_id=testclient" -d "client_secret=***" "http://localhost:18080/realms/master/protocol/openid-connect/token" 

成功すると以下のようなaccess_tokenid_tokenを含むJSONが得られる。

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI0YzExNTIyOC03YWM2LTQzZTktOGM4Yy1mNmYzMWI0NDI2ZmIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6IjI2ZTUwZmMwLTkzYjAtNDlkZS04MTFlLThhMGNiNmRmOGIyMSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.gedpNdUYNSMq7Pbj6OGUMjFqoC17Ab1MPyLXfyKlLjD2o4mzS30WxeLQzEFYpAeU2Bd--Pcc-o2nejFn3SMlh5_K5HcfCLkq72cw6-_OyqWcPaNBs9d9fXiL0t9tazhJf44kUSaUzbXgp1QOHf7s0Nf4TmhkwwyafpTVEH6pycVDLJ4Ftv8Qwbucub3otGJeVSDtqjodThijulKyZfJmVKPtdImrFejonPeK89lJuxGcGk4mTCHl-Xu_PkBu4l73lw1tKLEMBktM7RPJ7lMGykhjNNSwBFDzzHUBLX_k-VwrOERr7oAPJy48sitgo_zgWo4zFtcCjalMIdB8Y3u6kQ","expires_in":60,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2MWQxZTI2Mi1jMjYyLTQzN2MtODJkOS1lN2UwMTA0MDQwM2EifQ.eyJleHAiOjE3Mzg2MDM5OTgsImlhdCI6MTczODYwMjE5OCwianRpIjoiMTkzYTUxYzYtZWQ3Yi00NGIzLTg5ZGMtOGYwMDhjNTZlMTdkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDoxODA4MC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDoxODA4MC9yZWFsbXMvbWFzdGVyIiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6InRlc3RjbGllbnQiLCJzaWQiOiIyNmU1MGZjMC05M2IwLTQ5ZGUtODExZS04YTBjYjZkZjhiMjEiLCJzY29wZSI6Im9wZW5pZCBlbWFpbCBhY3Igd2ViLW9yaWdpbnMgcHJvZmlsZSByb2xlcyBiYXNpYyJ9.mgkiF-kO8U-9kQT3GLcmt0qvpt2Qcx9mkF6d2ia4VrVE1pMdmkTHQTsjXAuPPurHYvAwFTWz6QDl_1K0-kCNTg","token_type":"Bearer","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI1ZmUxMDM1MS1jNGRkLTQxZGItOGIwMi1kOTA5NWRiMDg4MDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJ0ZXN0Y2xpZW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0Y2xpZW50Iiwic2lkIjoiMjZlNTBmYzAtOTNiMC00OWRlLTgxMWUtOGEwY2I2ZGY4YjIxIiwiYXRfaGFzaCI6IkNvUXotYnF3cE1PNExWUmRGWmZlV0EiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoi5aSq6YOOIOWxseeUsCIsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbGhpMDkiLCJnaXZlbl9uYW1lIjoi5aSq6YOOIiwiZmFtaWx5X25hbWUiOiLlsbHnlLAiLCJlbWFpbCI6ImRlbGhpMDlAZXhhbXBsZS5jb20ifQ.VeGrAQqjHsZNbmr3u41mA_aU5xoSn5ZcXCbsQLzinJSXs1nl8GQ0soosbDPr6ZcmXVxNYzuAvG-h-Xae2F2b5UJ-YOLNHFP_xV7BG1LUydqm3cEBaRztbWAwr_WHoLm0tA5wtRvohDHLVhm19A05V-ADLc2HQ4SZRR_BcLrKMxncqjurA58KHxJpy0gT1AFmN2GWVYnSr6VkUmFyLJiObnyP3ccJlauiyJljjRfCxBNkAR4JotU57kH4HBQnopt3fS6bIaoOSm0f09vtXpBSMVvSWu9RvTBUNmeTYt2lPPhqMW9HaqGgrc6Amu3rTy3OAb1LFCwaPIgffQRoNQ_4rg","not-before-policy":0,"session_state":"26e50fc0-93b0-49de-811e-8a0cb6df8b21","scope":"openid email profile"}

IDトークンとアクセストークンはJWTだからただのbase64された文字列なので、以下のコマンドでデコードできる。

$ echo "トークン"|cut -d "." -f2 | base64 -d

実際に実行してみる。

echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI0YzExNTIyOC03YWM2LTQzZTktOGM4Yy1mNmYzMWI0NDI2ZmIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6IjI2ZTUwZmMwLTkzYjAtNDlkZS04MTFlLThhMGNiNmRmOGIyMSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.gedpNdUYNSMq7Pbj6OGUMjFqoC17Ab1MPyLXfyKlLjD2o4mzS30WxeLQzEFYpAeU2Bd--Pcc-o2nejFn3SMlh5_K5HcfCLkq72cw6-_OyqWcPaNBs9d9fXiL0t9tazhJf44kUSaUzbXgp1QOHf7s0Nf4TmhkwwyafpTVEH6pycVDLJ4Ftv8Qwbucub3otGJeVSDtqjodThijulKyZfJmVKPtdImrFejonPeK89lJuxGcGk4mTCHl-Xu_PkBu4l73lw1tKLEMBktM7RPJ7lMGykhjNNSwBFDzzHUBLX_k-VwrOERr7oAPJy48sitgo_zgWo4zFtcCjalMIdB8Y3u6kQ"|cut -d "." -f2 | base64 -d
{"exp":1738602258,"iat":1738602198,"auth_time":1738602172,"jti":"4c115228-7ac6-43e9-8c8c-f6f31b4426fb","iss":"http://localhost:18080/realms/master","aud":"account","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"Bearer","azp":"testclient","sid":"26e50fc0-93b0-49de-811e-8a0cb6df8b21","acr":"1","allowed-origins":["http://localhost"],"realm_access":{"roles":["default-roles-master","offline_access","uma_authorization"]},"resource_access":{"account":{"roles":["manage-account","manage-account-links","view-profile"]}},"scope":"openid email profile","email_verified":false,"name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com

blogUrlは含まれていないことが分かる。

トークンに属性を追加する

Keycloakの管理画面の「Client scopes」というメニューを開く。emailprofileなど、認可リクエストのscopeに使用可能なパラメータが定義されている。

今回はprofilescopeに渡した時にblogUrlも含まれてほしいので、profileを選択する。

メニューの「Mappers」を開くとprofileに紐づく属性が定義されているっぽい。

例えばトークンにすでに属性として含まれているgiven_nameの詳細を開くと、以下のように定義されている。

  • Add to ID token
  • Add to access token

という項目があり、これが有効になっているからトークンに属性が含まれていると考えられる。

同じようにblog_urlを追加するとよさそうである。

「Add mapper」を押すと「From predefined mappers」と「By configuration」という選択肢が出てくる。「By configuration」を選択する。

「User Attribute」を選択する。

以下のようにmapperblogという名前で作成する。

「Add to ID token」と「Add to access token」はデフォルトで有効になっていたのでそのまま作成する。

profileblogが登録された。

先の検証手順と同じようにトークンエンドポイントを叩いてトークンを取得すると、以下のようにIDトークン・アクセストークンともにblog_urlを追加できた。

echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDQ4MzUsImlhdCI6MTczODYwNDc3NSwiYXV0aF90aW1lIjoxNzM4NjA0NzUyLCJqdGkiOiJlNTFkMTVlNC1jYTNjLTRlYzYtODAyZC02OGEyNWYwMTdjM2EiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJ0ZXN0Y2xpZW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0Y2xpZW50Iiwic2lkIjoiYzIwZWU0YTktYjBjNy00NjYwLTlmYjEtNWQ5NGE1ZDZhMGE0IiwiYXRfaGFzaCI6IlhnanQ1MENyZkNCWEtTSmRjcVduNFEiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJibG9nX3VybCI6Imh0dHBzOi8va2FtYXRpbWFydS5oYXRlbmFibG9nLmNvbS8iLCJuYW1lIjoi5aSq6YOOIOWxseeUsCIsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbGhpMDkiLCJnaXZlbl9uYW1lIjoi5aSq6YOOIiwiZmFtaWx5X25hbWUiOiLlsbHnlLAiLCJlbWFpbCI6ImRlbGhpMDlAZXhhbXBsZS5jb20ifQ.KfvLl9vEnYctPZADTIJ7JjzlLX0RHXxzmwQ1g5WFbx4ZezMKSCQvIj-DlQHcrQQYVAcjAR9-WOt_O2wMiPCyQD9jp0EHklfFdWAMkIKw3-3dKO5NqElijgLGFP70xDh5g70e2BYRBNbrTcMpcZdS5R1APR7XESK4HC4of0uJfEVqzWlirvTXEv2arJAqzutyeHHeptS6-GjitkUi17X_pOPK_j9apUEwc644FH9ZmWVIkiiKKSRvcVbqS_Ca_P0BJitVvj7OphONTAUxgGp2mVH-kZL1uWP19nctfrkIMIQszxPcR1e6KbqNV1hg4Kvb_jCOaV4eok910vQOS00JQA"|cut -d "." -f2 | base64 -d
{"exp":1738604835,"iat":1738604775,"auth_time":1738604752,"jti":"e51d15e4-ca3c-4ec6-802d-68a25f017c3a","iss":"http://localhost:18080/realms/master","aud":"testclient","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"ID","azp":"testclient","sid":"c20ee4a9-b0c7-4660-9fb1-5d94a5d6a0a4","at_hash":"Xgjt50CrfCBXKSJdcqWn4Q","acr":"1","email_verified":false,"blog_url":"https://kamatimaru.hatenablog.com/","name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com"
echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDQ4MzUsImlhdCI6MTczODYwNDc3NSwiYXV0aF90aW1lIjoxNzM4NjA0NzUyLCJqdGkiOiIwNWFlMWE3MC1kOWNlLTRjYTItODY4Zi00OWU5ZjQ4NTIxY2MiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6ImMyMGVlNGE5LWIwYzctNDY2MC05ZmIxLTVkOTRhNWQ2YTBhNCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiYmxvZ191cmwiOiJodHRwczovL2thbWF0aW1hcnUuaGF0ZW5hYmxvZy5jb20vIiwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.YdhN0_IUtQi4abe8uz3rfnwP5R4xCKs6AIT-2kT0Z-SEFK2pzx3R3zGGl_BuSVscc2V946Bwyt_QVgNdKJv0cK6oH4oocqkSbIJUkGJUWb-gzggXmrdY2c6CjYZrE-zXf_meJjVpU-jTh9LPF3fvzyrLpTIv1yWZkUkx8ESpWLMtGHTcC3HZe4S4Dl4VhhhFWSM4zUqnBdkaWbesqNysbWGTHNBjV8mLZG8hEKd5X3jpmaF5pImihVJDfagj2_cRarog9rFvHd5HbAcyN8DV_ksKJXz9U_2L5PY18W1gBZT57IpGa-l09RNFPyrrQiKkm7SIuDuGBGizApr_kNDuuQ"|cut -d "." -f2 | base64 -d
{"exp":1738604835,"iat":1738604775,"auth_time":1738604752,"jti":"05ae1a70-d9ce-4ca2-868f-49e9f48521cc","iss":"http://localhost:18080/realms/master","aud":"account","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"Bearer","azp":"testclient","sid":"c20ee4a9-b0c7-4660-9fb1-5d94a5d6a0a4","acr":"1","allowed-origins":["http://localhost"],"realm_access":{"roles":["default-roles-master","offline_access","uma_authorization"]},"resource_access":{"account":{"roles":["manage-account","manage-account-links","view-profile"]}},"scope":"openid email profile","email_verified":false,"blog_url":"https://kamatimaru.hatenablog.com/","name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com