【Keycloak】 FAPI Baseline 対応のクライアントを Go 言語で実装する
過去4回に渡り、Keycloak と Go 言語で Authorization Code Flow を学びました。今回は、Keycloak で Financial-grade API (FAPI) Baseline に対応するためのクライアントを Go 言語で実装します。
- KeycloakとGo言語でAuthorization Code Flowを学ぶ
 - KeycloakとGo言語でAuthorization Code Flowを学ぶ 2
 - KeycloakとGo言語でAuthorization Code Flowを学ぶ 3
 - KeycloakとGo言語でAuthorization Code Flowを学ぶ 3
 
| バージョン | |
|---|---|
| Mac | Sonoma 14.5 | 
| Keycloak | 24.0.3 | 
| Docker | 26.0.0 | 
| Docker Compose | v2.24.5 | 
| Terraform | 1.8.4 | 
https://github.com/kntks/blog-code/tree/main/2024/07/keycloak-fapi-baseline
Client Authentication
Section titled “Client Authentication”トークンエンドポイントを使用してアクセストークンや ID トークンを取得したい場合、先にクライアント認証をする必要があります。Keycloak で Confidential クライアントタイプを使用したクライアント認証方法と OIDC の Client Authentication 以下のように対応しています。
| OIDC | Keycloak | 
|---|---|
| client_secret_basic | Client Id and Secret | 
| client_secret_post | Client Id and Secret | 
| private_key_jwt | Signed Jwt | 
| client_secret_jwt | Signed Jwt using Client Secret | 
参考:
- 9. Client Authentication - OpenID Connect Core 1.0 incorporating errata set 1
 - Confidential client credentials - Keycloak Documentation
 
Keycloakのセットアップ
Section titled “Keycloakのセットアップ”Keycloak を docker compose で起動します。
services:  keycloak:    image: quay.io/keycloak/keycloak:24.0.3    ports:      - target: 8080        published: 8080        protocol: tcp        mode: host    environment:      - KEYCLOAK_ADMIN=admin      - KEYCLOAK_ADMIN_PASSWORD=admin    command: ["start-dev"]Terraform を使用するために Keycloak側のセットアップ が必要です。
client_id     = "terraform"client_secret = "client secretをコピペ"url           = "http://localhost:8080"terraform apply で Keycloak の設定を行います。
cd terraformterraform initterraform planterraform apply -auto-approveGo言語のコードは以前書いた、KeycloakとGo言語でAuthorization Code Flowを学ぶ 3 で使用したコードをベースとして利用します。
https://github.com/kntks/blog-code/tree/main/2024/04/keycloak-authorization-code-flow-3
APIサーバー起動してから、ブラウザで http://localhost:8081/login にアクセスします。
go run base/*.goKeycloak のログイン画面が表示されるので、username と password に myapp を入力してログインできます。
クライアントポリシー
Section titled “クライアントポリシー”Keycloak は、クライアントが Financial-grade API (FAPI) をサポートするための設定を提供しています。
クライアントが FAPI に対応していることを示すために、Keycloak ではクライアントポリシーを使用します。 クライアントポリシーはクライアント・アプリケーションをセキュアにするための設定で、主に以下のことができます。
- クライアントがどのような構成を持つことができるかについてのポリシーを設定する
 - クライアント設定の検証
 - 金融グレードAPI(FAPI)やOAuth 2.1など、要求されるセキュリティ標準やプロファイルへの準拠
 
参考:
- 2.7. Financial-grade API (FAPI) Support - Keycloak Documentation
 - Client Policies - Keycloak Documentation
 
クライアントポリシーの設定
Section titled “クライアントポリシーの設定”Realm settings -> Client policies -> Policies から Client client policy を選択することで、クライアントポリシーを設定できます。

デフォルトではクライアントポリシーが存在しないため、実際にクライアントポリシーを作成してみます。名前は fapi にします。

Save ボタンを押した後、Conditions と Client profiles が表示されます。

Conditions
Section titled “Conditions”Condintions は、ポリシーがどのクライアントにいつ採用されるかを決定できます。今回は any-client に設定することで、すべてのクライアントに対してポリシーの適用をしますが、その他にも以下のような Condition があります。
- client-access-type
 - client-roles
 - client-scopes
 - client-updater-context
 - client-updater-source-groups
 - client-updater-source-host
 - client-updater-source-roles
 
詳細は Condition を参照してください。

Client profiles
Section titled “Client profiles”以下の画像で表示されているクライアントプロファイルは、Keycloak で提供されているデフォルトのプロファイルです。クライアントポリシーを作成する際に、これらのプロファイルを選択することで、FAPI や OAuth 2.1 のような標準的なセキュリティプロファイルに準拠するように設定できます。

クライアントポリシーの適用後
Section titled “クライアントポリシーの適用後”クライアントポリシーを作成後以下のようになっているはずです。

1つ前のセクションでAPIサーバー起動してから、ブラウザで http://localhost:8081/login にアクセスしました。
しかし、クライアントポリシー適用後に再度ログインを試みると、URLが以下のようになり、ログイン画面が表示されなくなりました。
http://localhost:8081/callback?error=invalid_request&error_description=Invalid+redirect_uri&state=xxxxxxFAPI のポリシーが適用されているようです。ここからリクエストを成功させるために、どのような設定が必要なのか調査し、API サーバーの実装を変更します。
クライアントが対応すべき要件
Section titled “クライアントが対応すべき要件”Go 言語で作成した API サーバーは confidential client です。そのため 5.2.4. Confidential client から要件を確認します。するとこのセクションの1行目に「In addition to the provisions for a public client」と記載されていることから、public client に対する要件も同時に確認する必要があることがわかります。
7.1. TLS and DNSSEC considerationss では、すべてのやり取りはTLS(HTTPS)で暗号化されなければならない。と記載されています。さらに 5.2.2. Authorization server でもリダイレクト URI に対して https の使用が要求されています。
public client に対する要件
Section titled “public client に対する要件”- RFC7636 (PKCE) のサポート
 - RFC7636 (PKCE) にあるコードチャレンジ方式として 
S256を用いたものを使用すること - 対話する各認証サーバーに対して、別個のリダイレクト URI を使用すること
 - リソースオーナーのユーザーエージェント(ブラウザなど)のセッションにリダイレクト URI 値を保存し、認可応答を受け取ったリダイレクト URI と比較しなければならない。URI が一致しない場合、クライアントはエラーで処理を終了しなければならない
 - 効果的な CSRF 防御を実装しなければならない
 
public clientが ID トークンを要求する場合
- scope に openid を含める
 - 認証リクエストに、OIDCのセクション3.1.2.1 で定義されている nonce パラメータを含める。
 
scope に openid が存在しない場合(つまり ID トークンを要求しない場合かな?)
- RFC6749のセクション4.1.1 で定義されている state パラメータを含める
 - トークンレスポンスで取得した scope が完全に一致するか、または認可リクエストで送信されたスコープのサブセットを含むことを検証しなければならない。
 - OIDF または RFC8414 に定義される well known endpoint で認可サーバーによって公開されたメタデータドキュメントから取得された認可サーバーメタデータのみを使用すること
 
参考:5.2.3. Public client - Financial-grade API Security Profile 1.0 - Part 1: Baseline
confidential client に対する要件
Section titled “confidential client に対する要件”- トークン・エンドポイントに対して認証するために以下の2つ両方をサポートしなければならない
- MTLSのセクション2 に規定されている OAuth クライアント認証のための相互 TLS(mTLS)
 - OIDCのセクション9 に規定されている 
client_secret_jwtまたはprivate_key_jwt 
 - RSA 暗号を使用する場合、最低 2048 ビットの RSA 鍵を使用しなければならない
 - 楕円曲線暗号を使用する場合、最低160ビットの楕円曲線鍵を使用しなければならない
 - 共通鍵暗号を使用する場合、クライアントシークレットが最低 128 ビットであることを検証しなければならない
 
今までのブログで書いてきたコードは、client_secret_post を使用していましたが、これを client_sercret_jwt または private_key_jwt に変更します。
参考:
- 9. Client Authentication - OpenID Connect Core
 - 5.2.4. Confidential client - Financial-grade API Security Profile 1.0 - Part 1: Baseline
 
FAPI に準拠するためのコード修正
Section titled “FAPI に準拠するためのコード修正”クライアントの設定を変更します。そのときクライアントポリシーによって設定の変更ができないため、Client policies で作成した fapi ポリシーを disable にしておきます。

PKCE を使用する
Section titled “PKCE を使用する”PKCE については以前のブログで書いたので、コードはすでに実装があります。
Proof Key for Code Exchange (PKCE)
https://github.com/kntks/blog-code/blob/main/2024/07/keycloak-fapi-baseline/base/main.go#L64-L79
func handleLogin(w http.ResponseWriter, r *http.Request) {  ...  codeVerifier, err := generateRandomString(64)
  v := url.Values{}  v.Add("scope", "openid")  v.Add("response_type", "code")  v.Add("client_id", clientID)  v.Add("redirect_uri", "http://localhost:8081/callback")  v.Add("state", state)  v.Add("nonce", nonce)  v.Add("code_challenge", generateCodeChallenge(codeVerifier))  v.Add("code_challenge_method", "S256")  ...}nonce を必須にする
Section titled “nonce を必須にする”今回使用するコードではすでに nonce を設定しているため、この条件を満たしています。
https://github.com/kntks/blog-code/blob/main/2024/07/keycloak-fapi-baseline/base/main.go#L77
v := url.Values{}v.Add("scope", "openid")v.Add("response_type", "code")v.Add("client_id", clientID)v.Add("redirect_uri", "http://localhost:8081/callback")v.Add("state", state)v.Add("nonce", nonce)v.Add("code_challenge", generateCodeChallenge(codeVerifier))v.Add("code_challenge_method", "S256")API サーバーを HTTPS にする
Section titled “API サーバーを HTTPS にする”以下のコマンドを実行して証明書を作成します。
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/C=JP/ST=Tokyo/L=Tokyo/CN=localhost"key.pem と cert.pem が作成されました。
$ tree -L 2 -I "terraform|base|compose*|go*".├── cert.pem├── key.pem└── tls    ├── claims.go    ├── jwk.go    └── main.gohttps://localhost:8443/login にアクセスするために、API サーバーを HTTPS に変更します。
func main() {  http.HandleFunc("/login", handleLogin)  http.HandleFunc("/callback", handleCallback)  http.HandleFunc("/home", handleHome)
  fmt.Println("Server is running on :8081")  fmt.Println("Server is running on :8443")
  log.Fatal(http.ListenAndServe(":8081", nil))  log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil))}
func handleLogin(w http.ResponseWriter, r *http.Request) {  ...
  v := url.Values{}  v.Add("scope", "openid")  v.Add("response_type", "code")  v.Add("client_id", clientID)  v.Add("redirect_uri", "http://localhost:8081/callback")  v.Add("redirect_uri", "https://localhost:8443/callback")  v.Add("state", state)  v.Add("nonce", nonce)  v.Add("code_challenge", generateCodeChallenge(codeVerifier))  v.Add("code_challenge_method", "S256")
  ...}
func handleCallback(w http.ResponseWriter, r *http.Request) {  ...
  v := url.Values{}  v.Add("grant_type", "authorization_code")  v.Add("code", code)  v.Add("client_id", clientID)  v.Add("client_secret", clientSecret)  v.Add("redirect_uri", "http://localhost:8081/callback")  v.Add("redirect_uri", "https://localhost:8443/callback")  v.Add("code_verifier", codeVerifier.Value)
  ...}https://github.com/kntks/blog-code/pull/31/commits/fba2ebd1dbd241b4c2ca562941b6141f332da8a8
参考:https://pkg.go.dev/net/http#ListenAndServeTLS
その後、redirect_uri を https://localhost:8443/callback に変更します。

以下のコマンドで API サーバーを起動します。
go run tls/*.go先ほど Web ブラウザで https://localhost:8443/login にアクセスすると Keycloak のログイン画面が表示されませんでしたが、表示されるようになります。しかし、ログイン後に token is empty となり、まだエラーが残っています。Keycloak のログには、WARN  [org.keycloak.events] (executor-thread-33) type="CODE_TO_TOKEN_ERROR", realmId="9bfe15a2-1b59-46de-a41d-f756e4ddb616", clientId="myapp", userId="null", ipAddress="172.29.0.1", error="invalid_code", grant_type="authorization_code", client_auth_method="client-secret" が表示されます。
このエラーは、クライアント認証で client_secret_jwt または private_key_jwt を使用すると解消できます。
※ SameSite=Strict だと Cookie が送信されないため、コード内に存在する SameSite の設定を SameSite=Lax に変更しています。
リダイレクトURIを比較する
Section titled “リダイレクトURIを比較する”public client に対する要件の中に、“リソースオーナーのユーザーエージェント(ブラウザなど)のセッションにリダイレクト URI 値を保存し、認可応答を受け取ったリダイレクト URI と比較しなければならない” とありました。
今回は、Web ブラウザの Cookie にリダイレクト URI を保存し、認可応答を受け取ったリダイレクト URI と比較します。
const (  redirectURI = "https://localhost:8443/callback")
func handleLogin(w http.ResponseWriter, r *http.Request) {  ...
  http.SetCookie(w, &http.Cookie{    Name:     "redirect_uri",    Value:    redirectURI,    Path:     "/",    HttpOnly: true,    Secure:   true,    SameSite: http.SameSiteLaxMode,  })
  ...}
func handleCallback(w http.ResponseWriter, r *http.Request) {  ...
  // リダイレクトURIの検証  {    r, err := r.Cookie("redirect_uri")    if err != nil {      log.Println(err)      http.Error(w, err.Error(), http.StatusBadRequest)      return    }    if r.Value != redirectURI {      log.Println("redirect_uri is not equal")      http.Error(w, "redirect_uri is not equal", http.StatusBadRequest)      return    }  }  ...
}https://github.com/kntks/blog-code/pull/31/commits/9d084051b792c98308356350ff7cf720e888b988
client_secret_jwt または private_key_jwt を使用する
Section titled “client_secret_jwt または private_key_jwt を使用する”ベースとなるコードでは、client_secret_basic または、client_secret_post を使用してクライアント認証を行っていました。しかし、FAPI に準拠するためには、その認証方法を client_secret_jwt または private_key_jwt に変更する必要があります。
client_secret_basic または、client_secret_post を使用している箇所
func handleCallback(w http.ResponseWriter, r *http.Request) {  ...  v := url.Values{}  v.Add("grant_type", "authorization_code")  v.Add("code", code)  v.Add("client_id", clientID)  v.Add("client_secret", clientSecret)  v.Add("redirect_uri", "http://localhost:8081/callback")  v.Add("code_verifier", codeVerifier.Value)
  payload := strings.NewReader(v.Encode())  req, _ := http.NewRequest("POST", tokenURL, payload)  req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
  // v.Add("client_secret", clientSecret) ではなく、Basic認証でもOK  // req.SetBasicAuth(clientID, clientSecret)client_secret_jwt に変更する
Section titled “client_secret_jwt に変更する”client_secret_jwt は client_secret_post や client_secret_basic と異なり、クライアントシークレットを直接送信するのではなく、JWT の署名に使用します。クライアントシークレットを共通鍵として使用し、JWT に署名を行います。これにより、認可サーバー(Keycloak)に直接クライアントシークレットを送信する必要がなくなります。
They can be used to demonstrate knowledge of some secret, such as a client secret, without actually communicating the secret directly in the transaction.
これらは、クライアントシークレットのような何らかの秘密の知識を、取引の中で直接その秘密を通信することなく示すために使用できます。
引用:RFC7521
JWT の Claim に入れる内容は OpenID Connect の仕様にある Client Authentication に説明があります。
| 項目 | 必須 or 任意 | 説明 | 
|---|---|---|
| iss | 必須 | OAuth クライアントの client_id にする | 
| sub | 必須 | OAuth クライアントの client_id にする | 
| aud | 必須 | 認証サーバーのトークンエンドポイントの URL にする | 
| jti | 必須 | トークンの一意な識別子。トークンの再利用を防ぐために使用できる | 
| exp | 必須 | JWTを処理に受け入れてはならない有効期限 | 
| iat | 任意 | JWTが発行された時刻 | 
トークンエンドポイントへのリクエストについて、RFC7521 のセクション 4.2 によると、 client_assertion と client_assertion_type をリクエストデータに追加するだけです。
ここからは実際に Keycloak の管理画面で設定を変更します。
Clients > Credentials > Client Authenticator で設定されている Client Authenticator を Client Id and Secret から Signed jwt with Client Secret に変更します。

Save ボタンを押すと、以下のようなダイアログが表示されるので、Yes を選択します。

リダイレクト URI を比較したときに作成したコードを使用します。
cp compare-redirect-uri/* client-secret-jwtclient_secret_jwt を使用するためには、JWT を署名する必要があります。Go 言語で JWT を署名するためには、以下のようにします。
func handleCallback(w http.ResponseWriter, r *http.Request) {  ...
  secretToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{    "iss": clientID,    "sub": clientID,    "aud": tokenURL,    "jti": time.Now().String(),    "iat": time.Now().Unix(),    "exp": time.Now().Add(time.Minute * 20).Unix(),  })
  // クライアントシークレットを使用してJWTを署名  signedToken, err := secretToken.SignedString([]byte(clientSecret))  if err != nil {    http.Error(w, err.Error(), http.StatusInternalServerError)    return  }
  v := url.Values{}  v.Add("grant_type", "authorization_code")  v.Add("code", code)  v.Add("client_id", clientID)  v.Add("client_secret", clientSecret)  v.Add("redirect_uri", redirectURI)  v.Add("code_verifier", codeVerifier.Value)  v.Add("client_assertion", signedToken)  v.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
  ...}https://github.com/kntks/blog-code/pull/31/commits/56881892828245206522fa8be3d822b6a8ee97a2
実際に API サーバーを起動して、ブラウザで https://localhost:8443/login にアクセスします。 Keycloak のログイン画面が表示され、ユーザー名、パスワードともに myuser でログイン後 home 画面にリダイレクトされることを確認できます。
go run client-secret-jwt/*.goprivate_key_jwt に変更する
Section titled “private_key_jwt に変更する”client_secret_jwt 認証方式では、クライアントシークレットを使用して JWT を署名しました。次は private_key_jwt 認証方式を試してみます。この方式では、client secret の代わりに非対称暗号を使用します。具体的には、クライアントが秘密鍵を使用して JWT に署名し、認可サーバーは対応する公開鍵を使用してその署名を検証します。
暗号鍵は confidential client に対する要件 に記載されているように、最低 2048 ビットの RSA 鍵または最低 160 ビットの楕円曲線鍵を使用する必要があります。今回はRSA 鍵を使用します。
JWT の Claim に入れる内容は OpenID Connect の仕様にある Client Authentication に説明があります。
| 項目 | 必須 or 任意 | 説明 | 
|---|---|---|
| iss | 必須 | OAuth クライアントの client_id にする | 
| sub | 必須 | OAuth クライアントの client_id にする | 
| aud | 必須 | 認証サーバーのトークンエンドポイントの URL にする | 
| jti | 必須 | トークンの一意な識別子。トークンの再利用を防ぐために使用できる | 
| exp | 必須 | JWTを処理に受け入れてはならない有効期限 | 
| iat | 任意 | JWTが発行された時刻 | 
リダイレクト URI を比較したときに作成したコードを使用します。
cp compare-redirect-uri/* private-secret-jwtここからは実際に Keycloak の管理画面で設定を変更します。
Clients > Credentials > Client Authenticator で設定されている Client Authenticator を Client Id and Secret から Signed jwt に変更します。

次に署名を行うための鍵を作成し、Keycloak に公開鍵を登録します。
この方法には2通りあります。
- 自前で秘密鍵、公開鍵を作成し、Keycloak の管理画面で公開鍵を設定する
 - Keycloak の管理画面で秘密鍵、公開鍵を作成し、秘密鍵をローカルに保存する
 
1つ目の方法は、こちらの記事で紹介されているため、このブログでは2つ目の方法を実施します。
管理画面左のナビゲーションペインから Clients をクリックし myapp の詳細画面から Keys タブがをクリックします。

Generate new keys ボタンを押した後、Archive Format で PKCS12 を選択し、残りはすべて myapp に設定します。 その後 Generate ボタンをクリックします。

openssl コマンドを使用して、keystore.p12 から秘密鍵を取り出します。
openssl pkcs12 -in keystore.p12 -nocerts -nodes -out private_key.pemGo言語にはファイルの中身を埋め込む機能があります。これを使用して秘密鍵を埋め込みます。
//go:embed private_key.pemvar privateKeyPem []byte
func handleCallback(w http.ResponseWriter, r *http.Request) {  ...
  privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPem)  if err != nil {    log.Println(err)    http.Error(w, err.Error(), http.StatusInternalServerError)    return  }
  secretToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{    "iss": clientID,    "sub": clientID,    "aud": tokenURL,    "jti": time.Now().String(),    "iat": time.Now().Unix(),    "exp": time.Now().Add(time.Minute * 20).Unix(),  })
  // クライアントシークレットを使用してJWTを署名  signedToken, err := secretToken.SignedString(privateKey)  if err != nil {    http.Error(w, err.Error(), http.StatusInternalServerError)    return  }
  v := url.Values{}  v.Add("grant_type", "authorization_code")  v.Add("code", code)  v.Add("client_id", clientID)  v.Add("client_secret", clientSecret)  v.Add("redirect_uri", redirectURI)  v.Add("code_verifier", codeVerifier.Value)  v.Add("client_assertion", signedToken)  v.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
  ...}https://github.com/kntks/blog-code/pull/31/commits/611dd8e347f7a6ce15f7764892742b038b5c5911
実際に API サーバーを起動して、ブラウザで https://localhost:8443/login にアクセスします。 Keycloak のログイン画面が表示され、ユーザー名、パスワードともに myuser でログイン後 home 画面にリダイレクトされることを確認できます。
go run private-secret-jwt/*.go参考:
- Confidential client credentials - Keycloak Documentation
 - 9. Client Authentication - OpenID Connect Core 1.0
 - 4.2. Using Assertions for Client Authentication - RFC7521
 - client_secret_jwt によるクライアント認証 - authlete
 
今回の記事では、Financial-grade API Security Profile 1.0 - Part 1: Baseline の資料を参考にしながら、Keycloak で FAPI に準拠するための設定を行いました。まだ FAPI Advanced については触れていませんが、今後の課題として取り組んでいきたいと思います。
あなたはプロのブロガーです。以下の文章を校正してください。
# 条件- 入力はマークダウン形式です- 出力もマークダウンで出力すること- URLは変更しないこと
# 入力
[RFC7521 のセクション 4.2](https://datatracker.ietf.org/doc/html/rfc7521#section-4.2) を確認すると、`client_secret_jwt` を使用するためには、トークンエンドポイントのリクエストボディに `client_assertion` と `client_assertion_type` を追加すればいいことがわかります。次の文章は、Client Authentication の説明をしています。以下の文章の続きを書いてください
`client_secret_jwt` 認証方式では、クライアントシークレットを使用して JWT を署名しました。次は `private_key_jwt` 認証方式を試してみます。JWT-Secured Authorization Request(JAR)
Section titled “JWT-Secured Authorization Request(JAR)”JARが解決する課題
Section titled “JARが解決する課題”どのような課題を解決するために提案された仕様なのか RFC9101 の冒頭部分を読むと以下のことがわかります。
認可サーバーへの認可リクエストURLは、以下のようにシリアライズされたクエリパラメータを含むことができます。
GET /authorize?  response_type=code  &scope=openid  &client_id=s6BhdRkqt3  &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb HTTP/1.1Host: server.example.com引用: 13.1. Query String Serialization - OpenID Connect Core 1.0 incorporating errata set 1
しかし、以下のような問題があります。
- ユーザーエージェントを介した通信は完全性が保護されていないため、パラメータが汚染される可能性がある(完全性保護の失敗)
 - 通信元が認証されていない(送信元認証の失敗)
 - ユーザーエージェントを介した通信が監視される可能性がある