第3回では、FAPI対応に必要なKeycloakの各種設定方法を紹介し、FAPI 1.0 - Part 2: Advanced(以降、FAPI 1.0とする)に準拠したクライアントアプリケーションとリソースサーバのリファレンス実装を用いてKeycloakの動作確認を行いました。第4回では、FAPI 1.0に準拠したクライアントアプリケーションとリソースサーバの作り方を説明し、さらにリファレンス実装の使い方を説明します。
FAPI 1.0に準拠したクライアントアプリケーションとリソースサーバの作り方
FAPI 1.0ではさまざまな要件が規定されており、FAPI 1.0に準拠したクライアントアプリケーションとリソースサーバを作るためには、それらの要件を満たすように実装する必要があります。ここでは、FAPI 1.0のすべての要件の実装方法に言及するのは難しいため、第1回で紹介したFAPI 1.0で特に注目すべき3つの要件を満たすための実装方法をFAPI 1.0に準拠したリファレンス実装のコードを基に説明します。
- 認可リクエストの改ざん対策
- 認可レスポンスの改ざん対策
- アクセストークン横取り対策
その他の実装方法については、GitHubにてリファレンス実装を公開しているので、そちらをご参照ください。また、リファレンス実装の言語にはJavaを使用し、フレームワークにはSpring Bootを使用しています。
前提
まずFAPI 1.0に準拠したクライアントアプリケーションとリソースサーバを作る上での前提として、FAPI 1.0に準拠したクライアントアプリケーションとリソースサーバが満たすフローを考えます。今回フローには、OpenID Connect Core 1.0(以降、OIDC)のHybrid Flowを使用します。ここで、OIDCのHybrid Flowを図 1に示します。今回、作り方を説明するクライアントアプリケーションとリソースサーバに関連する項目は、赤色の矢印にしています。
図1:OIDCのHybrid Flow
図1における各項目の詳細について表1にまとめます。図1における赤色の矢印に該当する箇所は太字下線にしています。
表1:OIDCのHybrid Flowの詳細
# | 項目 | 説明 |
1 | 使用開始 | エンドユーザはクライアントアプリケーションの使用を開始します |
2 | リクエストオブジェクト作成 | クライアントアプリケーションは使用が開始されると、認可リクエストパラメータ群をJWSにまとめたリクエストオブジェクトを作成します |
3 | 認可リクエスト | クライアントアプリケーションはブラウザを介して、Keycloakの認可エンドポイントにリクエストオブジェクトを含む認可リクエストを送ります。ここでは、request_uriパラメータではなく、requestパラメータを使用します。また、認可レスポンス改ざん対策にFAPI JWT Secured Authorization Response Mode for OAuth 2.0(JARM)を用いる方法ではなく、IDトークンを分離署名として用いる方法を使用するため、response_typeにはcode id_tokenを設定します |
4 | 認可リクエスト検証 | Keycloakは受け取った認可リクエストを検証し、認可リクエストが有効であることを確認します |
5 | ログイン画面表示 | Keycloakは受け取った認可リクエストが有効であることを確認すると、ログイン画面を返却し、ブラウザに表示します |
6 | ログイン | エンドユーザはブラウザに表示されたログイン画面にログイン情報を入力し、ログインボタンを押します。そして、ブラウザは入力されたログイン情報をKeycloakに送ります |
7 | 同意画面表示 | Keycloakはログイン情報を用いたエンドユーザの認証に成功すると、同意画面を返却し、ブラウザに表示します |
8 | 同意 | エンドユーザはブラウザに表示された同意画面の内容を確認し、同意します。そして、ブラウザは同意情報をKeycloakに送ります |
9 | 認可レスポンス | Keycloakはエンドユーザの同意情報を取得すると、ブラウザを介して、クライアントアプリケーションに認可コードを含む認可レスポンスを返却します |
10 | 認可レスポンス検証 | クライアントアプリケーションは受け取った認可レスポンスを検証し、認可レスポンスが有効であることを確認します |
11 | トークンリクエスト | クライアントアプリケーションは受け取った認可レスポンスが有効であることを確認すると、Keycloakのトークンエンドポイントに取得した認可コードを含むトークンリクエストを送ります |
12 | トークンリクエスト検証 | Keycloakは受け取ったトークンリクエストを検証し、トークンリクエストが有効であることを確認します |
13 | トークンレスポンス | Keycloakは受け取ったトークンリクエストが有効であることを確認すると、アクセストークンを含むトークンレスポンスをクライアントアプリケーションに返却します |
14 | トークンレスポンス検証 | クライアントアプリケーションは受け取ったトークンレスポンスを検証し、トークンレスポンスが有効であることを確認します |
15 | APIリクエスト | クライアントアプリケーションは受け取ったトークンレスポンスが有効であることを確認すると、リソースサーバにアクセストークンを含むAPIリクエストを送ります |
16 | APIリクエスト検証 | リソースサーバは受け取ったAPIリクエストを検証し、APIリクエストが有効であることを確認します |
17 | APIレスポンス | リソースサーバは受け取ったAPIリクエストが有効であることを確認すると、APIレスポンスをクライアントアプリケーションに返却します。 |
今回は図1のフローを満たすクライアントアプリケーションとリソースサーバを作る方法を説明していきます。
認可リクエストの改ざん対策の実装方法
認可リクエストの改ざん対策には、認可リクエストパラメータ群をまとめたJWT(JSON Web Token)であるリクエストオブジェクトを用いる方法を使用します。リクエストオブジェクトを用いた認可リクエスト改ざん対策の説明は第1回をご参照ください。今回、リクエストオブジェクト作成にNimbus JOSE+JWTというライブラリを使用します。以下にリクエストオブジェクトを作成するリファレンス実装のコードを示します。
リスト1:
01 | public String buildAndSign(JWK jwk) throws Exception { |
03 | JWSAlgorithm alg = (JWSAlgorithm) jwk.getAlgorithm(); |
07 | if (jwk.getKeyType() == KeyType.RSA) { |
08 | signer = new RSASSASigner((RSAKey) jwk); |
10 | signer = new ECDSASigner((ECKey) jwk); |
14 | JWTClaimsSet payload = this.claims.build(); |
15 | SignedJWT jws = new SignedJWT(new JWSHeader.Builder(alg).type(JOSEObjectType.JWT).keyID(jwk.getKeyID()).build(), |
20 | return jws.serialize(); |
まず署名アルゴリズムと署名鍵の設定をします。署名鍵は鍵タイプによって場合分けをします。そして、JWSのヘッダとペイロード部分を作成します。ヘッダにはalgヘッダパラメータなどの署名検証に必要な情報などを含め、ペイロードには認可リクエストパラメータ群とissクレームなどのJWTならではのクレームを含めます。リクエストオブジェクトのヘッダとペイロードをデコードしたものの例を以下に示します。ヘッダに該当する箇所は下記のリストの1~5行目、ペイロードに該当する箇所は6~20行目です。
リスト2:
02 | "kid": "P-Vl1UP9ZBAdrMmh8jNIXSLc310HDH1dBYQXb-cpdZs", |
08 | "response_type": "code id_token", |
09 | "code_challenge_method": "S256", |
10 | "nonce": "89308f0d-900c-4f79-aafd-4305ba51718b", |
11 | "client_id": "fapi-client", |
12 | "response_mode": "form_post", |
15 | "scope": "email openid profile", |
17 | "state": "963e62c9-4802-46c9-a296-412e2cf9bb52", |
19 | "code_challenge": "oUaZfOQ4Hzt5yeDhChQfEoWJyc_LX69N6GYZALKqouY" |
最後に、署名鍵で署名してリクエストオブジェクトを作成します。そして、作成したリクエストオブジェクトを認可リクエストのrequestパラメータに設定し、Keycloakに渡します。
認可レスポンスの改ざん対策の実装方法
認可レスポンスの改ざん対策には、IDトークンを分離署名(Detached Signature)として用いる方法を使用しています。IDトークンを分離署名として用いた認可レスポンス改ざん対策の説明は第1回をご参照ください。
今回、codeパラメータとstateパラメータから計算したハッシュ値が、IDトークンに格納されているc_hashクレームとs_hashクレームと一致しているかどうかの検証にNimbus JOSE+JWTというライブラリを使用します。以下にc_hashクレームとs_hashクレームを検証するリファレンス実装のコードを示します。検証に関係のない箇所は省略しています。
リスト3:
01 | public void verifiy(JWKSet jwkSet, JWK decryptionKey) throws Exception { |
04 | // c_hashクレームやs_hashクレームなどの検証 |
05 | jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(this.expectedClaimsSet.build(), |
06 | new HashSet<>(Arrays.asList("sub", "iat", "exp")))); |
10 | public IDTokenValidator code(String code) { |
13 | String cHash = CryptoUtil.calcurateXHash(code); |
16 | this.expectedClaimsSet.claim("c_hash", cHash); |
20 | public IDTokenValidator state(String state) { |
22 | // stateパラメータのハッシュ値の計算 |
23 | String sHash = CryptoUtil.calcurateXHash(state); |
25 | // stateパラメータのハッシュ値の格納 |
26 | this.expectedClaimsSet.claim("s_hash", sHash); |
計算したcodeパラメータとstateパラメータのハッシュ値が格納されているexpectedClaimsSetを使用して、c_hashクレームとs_hashクレームを検証します。expectedClaimsSetに格納するcodeパラメータとstateパラメータのハッシュ値を計算するリファレンス実装のコードを以下に示します。
リスト4:
01 | public static String calcurateXHash(String input) { |
03 | MessageDigest md = MessageDigest.getInstance("SHA-256"); |
04 | md.update(input.getBytes()); |
07 | byte[] digest = md.digest(); |
09 | // ハッシュ関数SHA-256で計算された値の左半分を取得 |
10 | byte[] leftHalf = Arrays.copyOfRange(digest, 0, digest.length / 2); |
12 | // Base64URLエンコードしたものを返却 |
13 | return Base64Url.encode(leftHalf); |
14 | } catch (Exception e) { |
ハッシュ関数SHA-256の計算をし、その左半分をBase64URLエンコードしてハッシュ値を計算します。
アクセストークン横取り対策の実装方法
アクセストークン横取り対策には、クライアント証明書を用いた送信者制約のあるアクセストークンを用いる方法(以降、OAuth MTLS)を使用します。OAuth MTLSの説明は第1回をご参照ください。
クライアント証明書のハッシュ値はアクセストークンのcnfクレームのx5t#S256ヘッダにバインドされており、トークンイントロスペクションによって取得できます。トークンイントロスペクションはexecuteTokenIntrospectionメソッドで行っており、こちらの実装方法はGitHubにて公開されているリファレンス実装をご参照ください。
トークンイントロスペクションによって、クライアント証明書のハッシュ値を取得できたら、APIリクエストからX.509証明書にパースされたクライアントアプリケーションのクライアント証明書を取得します。以下にクライアント証明書を取得するリファレンス実装のコードを示します。クライアント証明書の取得に関係のない箇所は省略しています。
リスト5:
1 | private void validateRequest(HttpServletRequest servletRequest) throws ServletException { |
4 | // クライアント証明書を取得し、X.509証明書にパース |
5 | X509Certificate[] certs = (X509Certificate[]) servletRequest |
6 | .getAttribute("javax.servlet.request.X509Certificate"); |
X.509証明書にパースされたクライアント証明書を取得できたら、クライアント証明書のハッシュ値の計算と検証をします。以下にハッシュ値の計算と検証をするリファレンス実装のコードを示します。
リスト6:
01 | private void validateCnfValue(X509Certificate[] certs, CNF cnf) throws ServletException { |
03 | // クライアント証明書が取得できているかの確認 |
04 | if (certs == null || certs.length == 0) { |
05 | throw new ServletException("request has no certificates information"); |
08 | // アクセストークンにクライアント証明書のハッシュ値が紐づけられているかの確認 |
09 | if (cnf.getX5t() == null) { |
10 | throw new ServletException("IntrospectionResponse has no cnf value"); |
14 | MessageDigest md = MessageDigest.getInstance("SHA-256"); |
16 | // クライアント証明書をDER形式にエンコーディング |
17 | md.update(certs[0].getEncoded()); |
20 | byte[] digest = md.digest(); |
23 | actual = Base64Url.encode(digest); |
24 | } catch (Exception e) { |
28 | // 計算したハッシュ値とcnfクレームのx5t#S256ヘッダにバインドされたハッシュ値の比較 |
29 | if (!cnf.getX5t().equals(actual)) { |
30 | throw new ServletException( |
31 | String.format("x5t#S256 is not match: expected [%s], actual [%s]", cnf.getX5t(), actual)); |
まずクライアントアプリケーションから、クライアント証明書を取得しているかどうかと,アクセストークンにクライアント証明書のハッシュ値が紐づけられているかどうかを確認します。確認ができたら、クライアント証明書をDER形式にエンコーディングし、ハッシュ関数SHA-256の計算をし、Base64URLエンコードをしてハッシュ値を計算します。最後に計算したハッシュ値とcnfクレームのx5t#S256ヘッダにバインドされたハッシュ値を比較します。一致していない場合は例外を発生させます。
今回の記事では言及していませんが、FAPI 1.0に準拠したクライアントアプリケーションとリソースサーバを作るためには、認可リクエストの改ざん対策、認可レスポンスの改ざん対策とアクセストークン横取り対策以外にも、さまざまな実装をする必要があります。以下にFAPI 1.0に準拠するために必要な実装項目の例を示します。
これらの実装方法はGitHubにてリファレンス実装を公開しているので、そちらをご参照ください。