Keycloak を試してみる

オープンソースアイデンティティ・アクセス管理ソフトウェアである Keycloak を試してみました。

Keycloak を OpenID Connect の Identity Provider としてセットアップし、クライアントと認可コードフローの動作を確認します。

Keycloak

www.keycloak.org

github.com

環境

Ubuntu 20.04 (x86_64)

セットアップ

Keycloak には3つの動作モードがあります。

  • Standalone
  • Standalone Cluster
  • Domain Cluster

今回は簡潔に Standalone モードで動かします。

公式の Docker Image を使います。設定をまとめたいので Docker Compose で起動することにしました。

docker-compose.yml

version: '3.9'
services:
  main:
    image: quay.io/keycloak/keycloak
    environment:
      - KEYCLOAK_USER=admin
      - KEYCLOAK_PASSWORD=admin
    ports:
      - '8080:8080'

管理者アカウントは admin:admin としました。

こちらの公式リポジトリの設定を参考にしています。

github.com

$ docker-compose up

レルムの作成

ブラウザでアクセスします。

Administration Console から、管理者としてログインします。

最初にレルム (Realm) を作成します。レルム毎にユーザー、認証、ロール、グループ等を構成できます。

デフォルトでは管理用の master レルムがあります。

sample レルムを作成しました。

いろいろ設定がありますが、今回は OpenID Connect のクライアントを作成して、挙動を見てみることにします。

ユーザー登録から見たいので、Login タブの User registration (ユーザーが自分でアカウントを作成できる) を ON にしておきます。

クライアントの作成

Clients を選択します。

最初からいくつか登録されていますが、右端の Create ボタンを選んで新しいクライアントを作成します。

名前は demo-client としました。

コンフィデンシャルクライアントにしたい場合は、作成後に Access Type を変更します。

しました。Credentials タブでクライアントシークレットを確認できます。

PKCE を有効にしたいので、Settings タブに戻り、Advanced Settings の Proof Key for Code Exchange Code Challenge Method を S256 に設定しておきます。

なお、ここまでブラウザ上で見てきましたが、管理機能は REST API でアクセス可能です。

www.keycloak.org

クライアントアプリケーションの作成

Golang で作成しました。

main.go

package main                                                                                                                                                              [69/173]

import (
        "crypto/rand"
        "crypto/sha256"
        "encoding/base64"
        "encoding/hex"
        "io"
        "log"
        "net/http"

        "github.com/coreos/go-oidc/v3/oidc"
        "golang.org/x/oauth2"
)

const (
        clientID     = "demo-client"
        clientSecret = "0464ece0-f6ce-417f-a811-1357ace9a672"
        issuer       = "http://192.168.1.110:8080/auth/realms/sample"
)

func makeSHA256String(s string) string {
        b := sha256.Sum256([]byte(s))
        return hex.EncodeToString(b[:])
}

func makeRandom() string {
        b := make([]byte, 32)
        rand.Read(b[:])
        return hex.EncodeToString(b[:])
}

var (
        verifier = makeRandom()
)

func main() {
        http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                if _, err := r.Cookie("Authorization"); err != nil {
                        provider, _ := oidc.NewProvider(r.Context(), issuer)
                        config := oauth2.Config{
                                ClientID:     clientID,
                                ClientSecret: clientSecret,
                                Endpoint:     provider.Endpoint(),
                                Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
                                RedirectURL:  "http://192.168.1.110:10080/callback",
                        }

                        state := makeSHA256String("foo")
                        nonce := makeSHA256String("bar")

                        codeChallengeByte := sha256.Sum256([]byte(verifier))
                        codeChallengeValue := base64.RawURLEncoding.EncodeToString(codeChallengeByte[:])
                        codeChallenge := oauth2.SetAuthURLParam("code_challenge", codeChallengeValue)
                        codeChallengeMethod := oauth2.SetAuthURLParam("code_challenge_method", "S256")

                        log.Printf("state: %#v, nonce: %#v, code_verifier: %#v", state, nonce, verifier)
                        url := config.AuthCodeURL(state, oidc.Nonce(nonce), codeChallenge, codeChallengeMethod)
                        http.Redirect(w, r, url, http.StatusFound)
                        return                                                                                                                                            [10/173]
                }
                io.WriteString(w, "Login success")
        })
        http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
                if err := r.ParseForm(); err != nil {
                        http.Error(w, "Parse form error", http.StatusInternalServerError)
                        log.Printf("%v", err.Error())
                        return
                }

                state := r.URL.Query().Get("state")
                log.Printf("Given state: %#v", state)

                ctx := r.Context()
                provider, _ := oidc.NewProvider(ctx, issuer)
                config := oauth2.Config{
                        ClientID:     clientID,
                        ClientSecret: clientSecret,
                        Endpoint:     provider.Endpoint(),
                        Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
                        RedirectURL:  "http://192.168.1.110:10080/callback",
                }

                codeVerifier := oauth2.SetAuthURLParam("code_verifier", verifier)
                accessToken, err := config.Exchange(ctx, r.Form.Get("code"), codeVerifier)
                if err != nil {
                        http.Error(w, "Couldn't get access token", http.StatusInternalServerError)
                        log.Printf("%v", err.Error())
                        return
                }

                rawIDToken, ok := accessToken.Extra("id_token").(string)
                if !ok {
                        http.Error(w, "Missing token", http.StatusInternalServerError)
                        return
                }

                oidcConfig := oidc.Config{
                        ClientID: clientID,
                }
                verifier := provider.Verifier(&oidcConfig)
                idToken, err := verifier.Verify(ctx, rawIDToken)
                if err != nil {
                        http.Error(w, "ID token verify error", http.StatusInternalServerError)
                        log.Printf("%v", err.Error())
                        return
                }

                idTokenClaims := map[string]interface{}{}
                if err := idToken.Claims(&idTokenClaims); err != nil {
                        http.Error(w, err.Error(), http.StatusInternalServerError)
                        log.Printf("%v", err.Error())
                        return
                }
                log.Printf("%#v", idTokenClaims)

                http.SetCookie(w, &http.Cookie{
                        Name:  "Authorization",
                        Value: "Bearer " + rawIDToken,
                        Path:  "/",
                })
                http.Redirect(w, r, "/", http.StatusFound)
        })
        log.Println("Start server")
        log.Println(http.ListenAndServe(":10080", nil))
}

qiita.com

こちらのコードをお借りして、state、nonce、PKCE パラメータを追加しました。

Keycloak とのやり取りを見たいだけなので、追加部分は手抜き実装です。値の検証もしていません。

接続確認

アプリを起動します。

$ go run main.go

アプリにアクセスします。今回の場合、即座に Keycloak の認証画面にリダイレクトされます。

ユーザーを作成するため、Register を選択します。

作成すると、アプリ側にリダイレクトされ Login Success と表示されます。うまくいったようです。

もう一度アプリにアクセスし、ログイン状態になっていることも確認できました。

IDトークンは以下のようになっていました。

{
  "acr": "1",
  "at_hash": "iNstDoWYxPpx4Za0Pwe04A",
  "aud": "demo-client",
  "auth_time": 1619065078,
  "azp": "demo-client",
  "email": "demo@example.org",
  "email_verified": false,
  "exp": 1619065378,
  "family_name": "ozaki",
  "given_name": "demo",
  "iat": 1619065078,
  "iss": "http://192.168.1.110:8080/auth/realms/sample",
  "jti": "ef2c09bd-cbc1-464d-a476-cacbe11fdad0",
  "name": "demo ozaki",
  "nonce": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9",
  "preferred_username": "demo",
  "session_state": "42c17589-a708-4c70-b496-daf9e5b27eae",
  "sub": "dc3e682f-d395-4c0f-8c46-c7c620f324b1",
  "typ": "ID"
}

まとめ

Keycloak と OpenID Connect クライアント間で、認可コードフローの動作を確認しました。

Keycloak はドキュメントや先人の事例が多いので助かります。

参考資料

Server Administration Guide

Keycloak by OpenStandia Advent Calendar 2017 - Qiita の各記事

OpenID Connectを使ったアプリケーションのテストのためにKeycloakを使ってみる - Qiita