オープンソースのアイデンティティ・アクセス管理ソフトウェアである Keycloak を試してみました。
Keycloak を OpenID Connect の Identity Provider としてセットアップし、クライアントと認可コードフローの動作を確認します。
Keycloak
環境
セットアップ
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
としました。
こちらの公式リポジトリの設定を参考にしています。
$ 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 でアクセス可能です。
クライアントアプリケーションの作成
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)) }
こちらのコードをお借りして、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 はドキュメントや先人の事例が多いので助かります。