Skip to content

【Keycloak】 FAPI Baseline 対応のクライアントを Go 言語で実装する

はじめに

過去4回に渡り、Keycloak と Go 言語で Authorization Code Flow を学びました。今回は、Keycloak で Financial-grade API (FAPI) Baseline に対応するためのクライアントを Go 言語で実装します。

環境

バージョン
MacSonoma 14.5
Keycloak24.0.3
Docker26.0.0
Docker Composev2.24.5
Terraform1.8.4

成果物

https://github.com/kntks/blog-code/tree/main/2024/07/keycloak-fapi-baseline

Client Authentication

トークンエンドポイントを使用してアクセストークンや ID トークンを取得したい場合、先にクライアント認証をする必要があります。Keycloak で Confidential クライアントタイプを使用したクライアント認証方法と OIDC の Client Authentication 以下のように対応しています。

OIDCKeycloak
client_secret_basicClient Id and Secret
client_secret_postClient Id and Secret
private_key_jwtSigned Jwt
client_secret_jwtSigned Jwt using Client Secret

参考:

環境準備

Keycloakのセットアップ

Keycloak を docker compose で起動します。

compose.yaml
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側のセットアップ が必要です。

terraform/terraform.tfvars
client_id = "terraform"
client_secret = "client secretをコピペ"
url = "http://localhost:8080"

terraform apply で Keycloak の設定を行います。

Terminal window
cd terraform
terraform init
terraform plan
terraform apply -auto-approve

Go言語のコードは以前書いた、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 にアクセスします。

Terminal window
go run base/*.go

Keycloak のログイン画面が表示されるので、username と password に myapp を入力してログインできます。

クライアントポリシー

Keycloak は、クライアントが Financial-grade API (FAPI) をサポートするための設定を提供しています。

クライアントが FAPI に対応していることを示すために、Keycloak ではクライアントポリシーを使用します。 クライアントポリシーはクライアント・アプリケーションをセキュアにするための設定で、主に以下のことができます。

  • クライアントがどのような構成を持つことができるかについてのポリシーを設定する
  • クライアント設定の検証
  • 金融グレードAPI(FAPI)やOAuth 2.1など、要求されるセキュリティ標準やプロファイルへの準拠

参考:

クライアントポリシーの設定

Realm settings -> Client policies -> Policies から Client client policy を選択することで、クライアントポリシーを設定できます。

client-policies-tab

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

create-client-policy

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

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-policy-condition

Client profiles

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

client-policy-profile

クライアントポリシーの適用後

クライアントポリシーを作成後以下のようになっているはずです。 client-policy-fapi-created client-policy-fapi-detail

1つ前のセクションでAPIサーバー起動してから、ブラウザで http://localhost:8081/login にアクセスしました。

しかし、クライアントポリシー適用後に再度ログインを試みると、URLが以下のようになり、ログイン画面が表示されなくなりました。

Terminal window
http://localhost:8081/callback?error=invalid_request&error_description=Invalid+redirect_uri&state=xxxxxx

FAPI のポリシーが適用されているようです。ここからリクエストを成功させるために、どのような設定が必要なのか調査し、API サーバーの実装を変更します。

クライアントが対応すべき要件

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 に対する要件

  • 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 に対する要件

  • トークン・エンドポイントに対して認証するために以下の2つ両方をサポートしなければならない
  • RSA 暗号を使用する場合、最低 2048 ビットの RSA 鍵を使用しなければならない
  • 楕円曲線暗号を使用する場合、最低160ビットの楕円曲線鍵を使用しなければならない
  • 共通鍵暗号を使用する場合、クライアントシークレットが最低 128 ビットであることを検証しなければならない

今までのブログで書いてきたコードは、client_secret_post を使用していましたが、これを client_sercret_jwt または private_key_jwt に変更します。

参考:

FAPI に準拠するためのコード修正

クライアントの設定を変更します。そのときクライアントポリシーによって設定の変更ができないため、Client policies で作成した fapi ポリシーを disable にしておきます。 client-policies-fapi-disable

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

base/main.go
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 を必須にする

今回使用するコードではすでに 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 にする

以下のコマンドを実行して証明書を作成します。

Terminal window
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 が作成されました。

Terminal window
$ tree -L 2 -I "terraform|base|compose*|go*"
.
├── cert.pem
├── key.pem
└── tls
├── claims.go
├── jwk.go
└── main.go

https://localhost:8443/login にアクセスするために、API サーバーを HTTPS に変更します。

tls/main.go
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 に変更します。 valid-redirect-uris-https

以下のコマンドで API サーバーを起動します。

Terminal window
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を比較する

public client に対する要件の中に、“リソースオーナーのユーザーエージェント(ブラウザなど)のセッションにリダイレクト URI 値を保存し、認可応答を受け取ったリダイレクト URI と比較しなければならない” とありました。

今回は、Web ブラウザの Cookie にリダイレクト URI を保存し、認可応答を受け取ったリダイレクト URI と比較します。

compare-redirect-uri/main.go
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 を使用する

ベースとなるコードでは、client_secret_basic または、client_secret_post を使用してクライアント認証を行っていました。しかし、FAPI に準拠するためには、その認証方法を client_secret_jwt または private_key_jwt に変更する必要があります。

client_secret_basic または、client_secret_post を使用している箇所

base/main.go
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 に変更する

client_secret_jwtclient_secret_postclient_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_assertionclient_assertion_type をリクエストデータに追加するだけです。

ここからは実際に Keycloak の管理画面で設定を変更します。

Clients > Credentials > Client Authenticator で設定されている Client AuthenticatorClient Id and Secret から Signed jwt with Client Secret に変更します。 signed-jwt-with-client-secret

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

リダイレクト URI を比較したときに作成したコードを使用します。

Terminal window
cp compare-redirect-uri/* client-secret-jwt

client_secret_jwt を使用するためには、JWT を署名する必要があります。Go 言語で JWT を署名するためには、以下のようにします。

client-secret-jwt/main.go
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 画面にリダイレクトされることを確認できます。

Terminal window
go run client-secret-jwt/*.go

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 を比較したときに作成したコードを使用します。

Terminal window
cp compare-redirect-uri/* private-secret-jwt

ここからは実際に Keycloak の管理画面で設定を変更します。

Clients > Credentials > Client Authenticator で設定されている Client AuthenticatorClient Id and Secret から Signed jwt に変更します。 client-authenticator-signed-jwt

次に署名を行うための鍵を作成し、Keycloak に公開鍵を登録します。

この方法には2通りあります。

  1. 自前で秘密鍵、公開鍵を作成し、Keycloak の管理画面で公開鍵を設定する
  2. Keycloak の管理画面で秘密鍵、公開鍵を作成し、秘密鍵をローカルに保存する

1つ目の方法は、こちらの記事で紹介されているため、このブログでは2つ目の方法を実施します。

管理画面左のナビゲーションペインから Clients をクリックし myapp の詳細画面から Keys タブがをクリックします。

client-myapp-keys-tab

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

openssl コマンドを使用して、keystore.p12 から秘密鍵を取り出します。

Terminal window
openssl pkcs12 -in keystore.p12 -nocerts -nodes -out private_key.pem

Go言語にはファイルの中身を埋め込む機能があります。これを使用して秘密鍵を埋め込みます。

参考:https://pkg.go.dev/embed

private-secret-jwt/main.go
//go:embed private_key.pem
var 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 画面にリダイレクトされることを確認できます。

Terminal window
go run private-secret-jwt/*.go

参考:

さいごに

今回の記事では、Financial-grade API Security Profile 1.0 - Part 1: Baseline の資料を参考にしながら、Keycloak で FAPI に準拠するための設定を行いました。まだ FAPI Advanced については触れていませんが、今後の課題として取り組んでいきたいと思います。