ABEJA Tech Blog

中の人の興味のある情報を発信していきます

サクッとKeycloakに入門してみた(OIDCによるログイン・JWT検証・カスタムクレーム)

背景

プラットフォームアプリケーショングループで開発を担当している平原です。 これまで利用していた認証サービスを自前でホストする必要が生じたので、 オープンソースの認証基盤を調査しました。 既存の認証システムはFirebase Authenticationを利用しており、JSON Web Token(JWT)とカスタムクレームを活用していたため、同様の機能を持つ認証基盤が必要でした。 いくつか調べてみたところ、Keycloakが比較的使われていそうだったため、 実際にどうやって使用するのかを試してみました。 この記事では、Keycloakを使ったOIDCによるログイン・JWT検証・カスタムクレームについて、 調査内容を備忘録的に残しておきます。 (細かいところは置いておいて、動かし切るところまでやります。)

Keycloakとは

Webアプリケーションやモバイルアプリの認証・認可を一元管理するためのオープンソースソフトウェアです。 シングルサインオン(SSO)機能を提供し、一度ログインすれば複数のサービスをシームレスに利用できるようになります。 OpenID ConnectやOAuth 2.0といった標準規格に準拠しており、ユーザー管理、多要素認証、ソーシャルログイン(GoogleやGitHubなど)にも対応しています。

動かしてみる

ローカル環境でKeycloakを立ち上げる

とにかく、ガチャガチャと動かす環境を手に入れるためにローカル環境を立ち上げてみます。 次のようなdocker-compose.yamlを作成して、docker compose upを実行すると、環境の立ち上げは完了します。

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.3.2
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://db:5432/keycloak
      KC_DB_USERNAME: keycloak_user
      KC_DB_PASSWORD: keycloak_password
    ports:
      - "8080:8080"
    command:
      - start-dev
    depends_on:
      - db

  # Keycloakの設定の永続化のために使用しています
  db:
    image: postgres:17.5
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak_user
      POSTGRES_PASSWORD: keycloak_password
    ports:
      - "5432:5432"
    volumes:
      - postgresql:/var/lib/postgresql/data

volumes:
  postgresql:

それでは管理画面にログインしてみます。 http://localhost:8080にアクセスするとログイン画面があらわれるので、ユーザー(admin)、パスワード(admin)を入力します。 (docker-compose.yamlのKC_BOOTSTRAP_ADMIN_USERNAME、KC_BOOTSTRAP_ADMIN_PASSWORDの値です。)

管理者ログイン画面

とりあえず管理画面には入れたようです。

管理者ログイン完了画面

概念を理解する

管理画面には入れましたが、それぞれ何を意味しているのかがわかりません。 とりあえず、ドキュメントなどを参考にどのようなものかを整理してみます。

  • realm(レルム):マルチテナント環境における各テナントに相当するもので、認証・認可に関する設定を完全に分離するための独立した領域となります。全てのリソースはrealmの配下に作成されます。日本語訳を調べてみると、「領域」のような意味を持っているようです。
  • users:システムにログインするユーザーのことです。グループに所属させることや、ロールを割り当てることができます。
  • groups:ユーザーをグループ化することができます。グループには属性とロールを関連づけることができます。グループに所属したユーザーはグループの属性とロールを継承するようです。
  • roles:ユーザーのカテゴライズに利用されます。
  • clients:いわゆる認証クライアントです。クライアントIDごとにリダイレクトのホスト名などが設定できます。
  • protocol mapper:ユーザー情報などをOIDCのトークンのクレームに変換する変換器です。カスタムクレームの設定に使用します。

これらの概念をツリー形式で表すと次のようになりそうです(LLMに書いてもらいました)。

realms/
├── {realm_id_1}/
│   ├── clients/
│   │   ├── {client_id_a}/
│   │   │   └── protocol_mappers/
│   │   │       └── {protocol_mapper_a}
│   │   └── {client_id_b}/
│   │       └── ...
│   ├── users/
│   │   ├── {user_id_x}/
│   │   │   └── user_role_mappings/
│   │   │       └── realm_roles/{realm_role_C}
│   │   └── {user_id_y}/
│   │       └── ...
│   ├── groups/
│   │   ├── {group_id_1}/
│   │   │   ├── members/
│   │   │   │   ├── {user_id_x}
│   │   │   │   └── {user_id_z}
│   │   │   └── group_role_mappings/
│   │   │       ├── realm_roles/{realm_role_C}
│   │   │       └── client_roles/{client_id_a}/{client_role_A}
│   │   └── {group_id_2}/
│   │       └── ...
│   └── realm_roles/
│       ├── {realm_role_C}
│       └── {realm_role_D}
└── {realm_id_2}/
    ├── ...

参考

OIDCでログインするための準備

まずはブラウザからOIDCを利用してログインをするところまで行きたいです。 フロントエンドのコードはありませんが、「Docker - Keycloak」にOIDCを行う直前までの手順は書いてありそうなので、それに従ってみます。

まずは、テナントに対応するrealmを作成します。 管理画面にログインした直後のmasterのrealmは管理用以外には使用してはいけないようです。 「Manage realms」→「Create realm」として、名前(myrealmとしています)を記載すれば作成できます。

realm作成1

realm作成2

次に、OIDCに利用するユーザーを作成します。 「Users」の「Create new user」を開き、記入欄を埋めるとユーザーが作成できます。 (ユーザー名を「myuser」としています。) そのままだと、パスワードがなくログインできないので、 先ほど作成した「myuser」の「Credentials」→「Set password」からパスワードをセットしてください。 何度かログインすることになるので、パスワードは忘れないようにします。

ユーザー作成1

ユーザー作成2

ユーザー作成3

ユーザー作成4

ユーザーが正しく作成されているかを確かめるために、一旦Keycloakにログインしてみます。 http://localhost:8080/realms/myrealm/accountにアクセスすると「myrealm」へのログイン画面が出るので、「myuser」とそのパスワードを入力してみます。 ユーザーのプロフィール画面が出れば成功です。

ログイン1

ログイン2

ログイン3

フロントエンドからOIDCでログインするために、OIDCクライアントを作成します。 「Clients」の「Create client」を開き、「Client type」を「OpenID Connect」、「Client ID」を「myclient」に設定します。 「Standard flow」にチェックがついていることを確認して次に進みます。 「Valid redirect URIs」に「http://localhost:3000/*」、「Valid post logout redirect URIs」「Web origins」に「http://localhost:3000」を入力すれば完了です。 これによって、リダイレクト先がlocalhost:3000に限定されます。 (リダイレクトURIは、ワイルドカードを使わず極力具体的なURIを指定するとより安全になります。)

OIDCクライアント作成1

OIDCクライアント作成2

OIDCクライアント作成3

OIDCクライアント作成4

カスタムクレームを設定

JWTを使う際、アクセストークンにユーザーの権限情報などをカスタムクレームに保存しておく場合があります。 ここでは、その方法を確認してみます。 protocol mapperには属性やロールなど様々な情報をアクセストークンに含める手法が用意されています。 今回は、ユーザー属性をマップしてみます。 このユーザー属性ですが、設定方法が2つありそうなので、2つの方法で試してみます。

Group Attributeを利用する方法

グループに所属するユーザーには、グループの属性が継承されます。 これを利用してユーザーに属性を関連づける方法を試してみます。 まず、「Groups」の「Create group」からグループ「mygroup」を作成します。

グループ作成1

グループ作成2

次に、グループの属性を設定し、ユーザーをグループに所属させてみます。 「groupAttribute」属性に「groupAttributeValue」を割り当てて、 「myuser」をグループに所属させるように設定を行います。

グループに属性を設定

グループにユーザーを追加

最後に、ユーザー属性をアクセストークンのクレームにマッピングする設定を行います。 「clients」の「myclient」の「Client scopes」タブを開きます。 「myclient-dedicated」をクリックするとマッパーを編集する画面が開きます。 「Configure a new mapper」から「User Attribute」を選択し、 ユーザー属性をマップする設定を作成します。 「Name」(管理画面上の名前)には「group attribute mapper」、「User Attribute」(マップ元となるユーザー属性名)には「groupAttributes」、「Token Claim Name」(マップ先となるクレーム名)には「groupClaim」と入れました。

マッパー(グループ)を作成1

マッパー(グループ)を作成2

マッパー(グループ)を作成3

マッパー(グループ)を作成4

ここまでできたらフロントエンドのページにいって、 ログインをし直すか「Refresh」ボタンをクリックしてJWTを更新します。 クレームを見てみると、「groupClaim」に「groupAttributeValue」が入っているはずです。

User Attributeを利用する方法

こちらでは、ユーザーに直接属性を関連づけてみます。 まず、カスタムユーザー属性の入力項目を作ります。 「Realm settings」の「User profile」から「Create Attribute」をクリックします。 「Attribute [Name]」に作成するユーザー属性名を入力します。 ここでは「userAttribute」としています。 こうすることで、ユーザーの詳細ページで新しい属性が入力できるようになります。

ユーザー属性の項目作成1

ユーザー属性の項目作成2

ユーザー「myuser」の「userAttribute」に値を設定してみます。 「Users」の「myuser」の「Details」タブを開くと、「User Attribute」という項目ができています。 ここに「userAttributeValue」と入力します。

ユーザー属性の設定

最後に、Group Attributeの時と同じようにマッパーを作成します。 「User Attribute」の欄だけグループの時と異なり、プルダウンから選択できるようになっています。 ここでは、「Name」に「user attribute mapper」、「User Attribute」に「userAttribute」、「Token Claim Name」に「userClaim」と入力しました。

マッパー(ユーザー)を作成

ここまでできたらフロントエンドのページにいって、 ログインをし直すか「Refresh」ボタンをクリックしてJWTを更新します。 クレームを見てみると、「userClaim」に「userAttributeValue」が入っているはずです。

audの設定

デフォルトでは、aud(Audience)クレームにはaccountが入りますが、このままでは使いにくいため変更します。

audの追加はGroup Attributeの時と同様にマッパーで行いますが、「User Attribute」の代わりに「Audience」を選択します。 「Included Client Audience」で作成したクライアントが選択できるので、「myclient」を選択します。 ここでは、「Name」に「audience mapper」と入力しています。

aud追加

元々audに入っていたaccountが残ってしまうため、これを削除しにいきます。 マッパーの追加画面に「Scope」のタブを開きます。 「Full scope allowed」をOFFに設定します。 (これがONの場合、realm内の全てのクライアントスコープが要求できてしまうため、OFFにしておくのが良いです。)

audのaccountを削除

ブラウザでOIDCでアクセストークンを取得

Keycloak側の準備が完了したので、フロントエンドの方を用意します。 まず、新規Next.jsプロジェクトを作成します。

$ node --version
v22.17.1
$ npx create-next-app@15.4.3
Need to install the following packages:
create-next-app@15.4.3
Ok to proceed? (y)

✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like your code inside a `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack for `next dev`? … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/taiki.hirahara/Documents/projects/keycloak-test/web/my-app.

必要なパッケージを入れます。 公式からKeycloakのクライアントライブラリが提供されているので、それを追加します。 OIDCに対応しているため、通常のOIDC用のライブラリでも良いはずですが、 TLSを利用しない環境や、自己証明書を利用している環境だとうまく動きませんでした。

$ npm install keycloak-js

src/app/page.tsxを変更します。 簡単な、ログイン、トークンリフレッシュ、ログアウトのボタンを用意します。 また、トークンの中身が見れるように画面に表示しておきます。 keycloak-jsから提供されるKeycloakはクラスでin-placeな変更を内部で行なってしまうため、 別のオブジェクトでラップしていつでも新しいオブジェクトとして状態を更新できるようにしています。

"use client";

import styles from "./page.module.css";
import Keycloak from "keycloak-js";
import { useEffect, useState } from "react";

export default function Home() {
  /** in-placeな変更が行われるため、変更検知を動かすために、オブジェクトでラップします */
  const [keycloakState, setKeycloakState] = useState<{
    keycloak: Keycloak | null;
  }>({ keycloak: null });
  useEffect(() => {
    const keycloak = new Keycloak({
      url: "http://localhost:8080", // KeycloakサーバーのURL
      realm: "myrealm", // 使用するレルム
      clientId: "myclient", // クライアントID
    });
    // 現在のログイン状態を確認します
    keycloak
      .init({ onLoad: "check-sso" })
      .then((authenticated) => {
        console.log({ authenticated });
        setKeycloakState({ keycloak });
      })
      .catch((error) => {
        console.error("Keycloak initialization failed", error);
      });
    // トークンの更新を定期的に行う
    const interval = setInterval(() => {
      keycloak
        .updateToken(30) // 30秒以内にトークンが期限切れになる場合、トークンを更新します
        .then((refreshed) => {
          if (refreshed) {
            console.log("Token was successfully refreshed");
            setKeycloakState({ keycloak }); // 新規オブジェクトでラップするため、Reactが変更を検知できます
          } else {
            console.log("Token is still valid");
          }
        })
        .catch((error) => {
          console.error("Failed to update token", error);
        });
    }, 10_000);
    return () => clearInterval(interval);
  }, []);
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <div>
          <button onClick={() => keycloakState.keycloak?.login()}>LOGIN</button>
          <button
            onClick={() => {
              keycloakState.keycloak?.updateToken(1_000).then((refreshed) => {
                if (refreshed) {
                  console.log("Token refreshed");
                  setKeycloakState({ ...keycloakState });
                }
              });
            }}
          >
            Refresh
          </button>
          <p>
            {keycloakState.keycloak?.authenticated
              ? "Authenticated"
              : "Not Authenticated"}{" "}
            token: {keycloakState.keycloak?.token}
          </p>
        </div>
      </main>
    </div>
  );
}

フロントエンドも完成したので、ログインを試してみます。 http://localhost:3000/にアクセスしてログインボタンを押すと、 Keycloakのログインページが現れます。 ログインに成功すると「Authenticated token:」の後ろにJWTが表示されます。

OIDCログイン1

OIDCログイン2

OIDCログイン3

取得したアクセストークンを検証

バックエンドで認証が通っているかを確認するためには、JWTが正しいことを確認する必要があります。 今回は、PythonとGoで試してみました。

Pythonで検証

環境構築をします。

$ python --version
Python 3.13.2
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install PyJWT@2.10.1 cryptography@45.0.5

次のようなmain.pyを作成してpython main.pyを実行します。

from pprint import pprint

import jwt
from jwt import PyJWKClient

# 参考: https://pyjwt.readthedocs.io/en/latest/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint
token = "<取得したJWT>"
url = "http://localhost:8080/realms/myrealm/protocol/openid-connect/certs"
optional_custom_headers = {"User-agent": "custom-user-agent"}
jwks_client = PyJWKClient(url, headers=optional_custom_headers)
signing_key = jwks_client.get_signing_key_from_jwt(token)
token = jwt.decode(
    token,
    signing_key,
    audience="myclient",
    issuer="http://localhost:8080/realms/myrealm",
    algorithms=["RS256"],
)
pprint(token)

Goで検証

Goの環境を作成します。

$ go version
go version go1.24.5 darwin/arm64
$ go mod init val-jwt
$ go get -u github.com/golang-jwt/jwt/v5
$ go get -u github.com/MicahParks/keyfunc/v3

次のようなmain.goを作成してgo run main.goを実行します。

package main

import (
    "fmt"
    "log"
    "slices"
    "time"

    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

// 参考
// - https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-ParseWithClaims-CustomClaimsType
// - https://github.com/MicahParks/keyfunc/blob/main/examples/keycloak/main.go
func main() {
    tokenString := "<取得したJWT>"

    // カスタムクレームの構造体を定義
    type MyCustomClaims struct {
        UserClaim  string `json:"userClaim"`
        GroupClaim string `json:"groupClaim"`
        jwt.RegisteredClaims
    }

    // 鍵取得関数を作成
    jwksURL := "http://localhost:8080/realms/myrealm/protocol/openid-connect/certs"
    jwks, err := keyfunc.NewDefault([]string{jwksURL})
    if err != nil {
        log.Fatalf("Failed to create JWK Set from resource at the given URL.\nError: %s", err)
    }

    // 署名の検証とクレームのパース
    token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, jwks.Keyfunc)
    if err != nil {
        log.Fatal(err)
    } else if claims, ok := token.Claims.(*MyCustomClaims); ok {
        // クレームを個別に検証

        // audience検証
        if auds, err := claims.GetAudience(); err != nil || len(auds) == 0 || !slices.Contains(auds, "myclient") {
            log.Fatal("invalid audience")
        }
        // issuer検証
        if iss, err := claims.GetIssuer(); err != nil || iss != "http://localhost:8080/realms/myrealm" {
            log.Fatal("invalid issuer")
        }
        // 有効期限検証
        if exp, err := claims.GetExpirationTime(); err != nil || exp.Before(time.Now()) {
            log.Fatal("invalid expiration time")
        }
        fmt.Printf("%#v\n", claims)
    } else {
        log.Fatal("unknown claims type, cannot proceed")
    }
}

本番までに気をつけたいところ

Dockerの起動コマンドがstart-devとなっており、起動ログにも開発モードであることが出力されています。 本番環境用に調整する必要がありそうです。 公式ドキュメントを参考に専用のイメージをビルドすると良さそうです。

また、一時的な管理ユーザーでログインしていることが、常に注意書きとして出ていました。 master realmで管理用のユーザーを作成して、一時ユーザーは削除しておく必要があります。 作成したユーザーにadminロールを割り当てておくと、管理ユーザーとしての権限が付与されます。

その他、Configuring Keycloak for productionに本番環境で動かす時に気をつけること(TLSやホスト名など)が書いてありそうです。 鍵の更新など細かい運用についてはServer Administration Guideを参考になりそうでした。 まだまだありそうですが、ガイドがたくさんあるので、困った時はこちらを参考にすることになりそうです。

まとめ

Keycloak特有の概念が多く最初に理解するのは時間がかかってしまいましたが、 実際に動かしながら結果を確認していくと、どれが何をしているのかが大体わかってきました。 また、認証・認可に特化したソフトウェアだけあってかなり高機能だと感じました。 今回、必要な部分のみ調べましたが、多要素認証やソーシャルログインもあり、 設定を永続化させるのも簡単にできてしまうので、 ローカルで何かを開発するときにも活用できそうだと感じました。