FAPI 1.0に準拠したクライアントアプリケーションと リソースサーバの作り方

2022年1月18日(火)
岡井 倫人(おかい みちと)
連載4回目となる今回は、FAPI 1.0に準拠したクライアントアプリケーションと リソースサーバの作り方を解説します。

第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: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トークンレスポンス検証クライアントアプリケーションは受け取ったトークンレスポンスを検証し、トークンレスポンスが有効であることを確認します
15APIリクエストクライアントアプリケーションは受け取ったトークンレスポンスが有効であることを確認すると、リソースサーバにアクセストークンを含むAPIリクエストを送ります
16APIリクエスト検証リソースサーバは受け取ったAPIリクエストを検証し、APIリクエストが有効であることを確認します
17APIレスポンスリソースサーバは受け取ったAPIリクエストが有効であることを確認すると、APIレスポンスをクライアントアプリケーションに返却します。

今回は図1のフローを満たすクライアントアプリケーションとリソースサーバを作る方法を説明していきます。

認可リクエストの改ざん対策の実装方法

認可リクエストの改ざん対策には、認可リクエストパラメータ群をまとめたJWT(JSON Web Token)であるリクエストオブジェクトを用いる方法を使用します。リクエストオブジェクトを用いた認可リクエスト改ざん対策の説明は第1回をご参照ください。今回、リクエストオブジェクト作成にNimbus JOSE+JWTというライブラリを使用します。以下にリクエストオブジェクトを作成するリファレンス実装のコードを示します。

リスト1:

public String buildAndSign(JWK jwk) throws Exception {
    // 署名アルゴリズムの設定
    JWSAlgorithm alg = (JWSAlgorithm) jwk.getAlgorithm();

    // 署名鍵の設定
    JWSSigner signer;
    if (jwk.getKeyType() == KeyType.RSA) {
        signer = new RSASSASigner((RSAKey) jwk);
    } else {
        signer = new ECDSASigner((ECKey) jwk);
    }

    // JWSのヘッダとペイロードの作成
    JWTClaimsSet payload = this.claims.build();
    SignedJWT jws = new SignedJWT(new JWSHeader.Builder(alg).type(JOSEObjectType.JWT).keyID(jwk.getKeyID()).build(),
            payload);

    // 署名鍵で署名
    jws.sign(signer);
    return jws.serialize();
}

まず署名アルゴリズムと署名鍵の設定をします。署名鍵は鍵タイプによって場合分けをします。そして、JWSのヘッダとペイロード部分を作成します。ヘッダにはalgヘッダパラメータなどの署名検証に必要な情報などを含め、ペイロードには認可リクエストパラメータ群とissクレームなどのJWTならではのクレームを含めます。リクエストオブジェクトのヘッダとペイロードをデコードしたものの例を以下に示します。ヘッダに該当する箇所は下記のリストの1~5行目、ペイロードに該当する箇所は6~20行目です。

リスト2:

{
  "kid": "P-Vl1UP9ZBAdrMmh8jNIXSLc310HDH1dBYQXb-cpdZs",
  "typ": "JWT",
  "alg": "PS256"
}
{
  "iss": "fapi-client",
  "response_type": "code id_token",
  "code_challenge_method": "S256",
  "nonce": "89308f0d-900c-4f79-aafd-4305ba51718b",
  "client_id": "fapi-client",
  "response_mode": "form_post",
  "aud": "https://localhost:8443/auth/realms/fapi",
  "nbf": 1637043363,
  "scope": "email openid profile",
  "redirect_uri": "https://localhost:8082/callback",
  "state": "963e62c9-4802-46c9-a296-412e2cf9bb52",
  "exp": 1637043663,
  "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:

public void verifiy(JWKSet jwkSet, JWK decryptionKey) throws Exception {
    …

    // c_hashクレームやs_hashクレームなどの検証
    jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(this.expectedClaimsSet.build(),
            new HashSet<>(Arrays.asList("sub", "iat", "exp"))));
    …
}

public IDTokenValidator code(String code) {

    // codeパラメータのハッシュ値の計算
    String cHash = CryptoUtil.calcurateXHash(code);

    // codeパラメータのハッシュ値の格納
    this.expectedClaimsSet.claim("c_hash", cHash);
    return this;
}

public IDTokenValidator state(String state) {

    // stateパラメータのハッシュ値の計算
    String sHash = CryptoUtil.calcurateXHash(state);

    // stateパラメータのハッシュ値の格納
    this.expectedClaimsSet.claim("s_hash", sHash);
    return this;
}

計算したcodeパラメータとstateパラメータのハッシュ値が格納されているexpectedClaimsSetを使用して、c_hashクレームとs_hashクレームを検証します。expectedClaimsSetに格納するcodeパラメータとstateパラメータのハッシュ値を計算するリファレンス実装のコードを以下に示します。

リスト4:

public static String calcurateXHash(String input) {
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(input.getBytes());

        // ハッシュ関数SHA-256の計算
        byte[] digest = md.digest();

        // ハッシュ関数SHA-256で計算された値の左半分を取得
        byte[] leftHalf = Arrays.copyOfRange(digest, 0, digest.length / 2);

        // Base64URLエンコードしたものを返却
        return Base64Url.encode(leftHalf);
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

ハッシュ関数SHA-256の計算をし、その左半分をBase64URLエンコードしてハッシュ値を計算します。

アクセストークン横取り対策の実装方法

アクセストークン横取り対策には、クライアント証明書を用いた送信者制約のあるアクセストークンを用いる方法(以降、OAuth MTLS)を使用します。OAuth MTLSの説明は第1回をご参照ください。

クライアント証明書のハッシュ値はアクセストークンのcnfクレームのx5t#S256ヘッダにバインドされており、トークンイントロスペクションによって取得できます。トークンイントロスペクションはexecuteTokenIntrospectionメソッドで行っており、こちらの実装方法はGitHubにて公開されているリファレンス実装をご参照ください。

トークンイントロスペクションによって、クライアント証明書のハッシュ値を取得できたら、APIリクエストからX.509証明書にパースされたクライアントアプリケーションのクライアント証明書を取得します。以下にクライアント証明書を取得するリファレンス実装のコードを示します。クライアント証明書の取得に関係のない箇所は省略しています。

リスト5:

private void validateRequest(HttpServletRequest servletRequest) throws ServletException {
    …

    // クライアント証明書を取得し、X.509証明書にパース
    X509Certificate[] certs = (X509Certificate[]) servletRequest
            .getAttribute("javax.servlet.request.X509Certificate");
    …
}

X.509証明書にパースされたクライアント証明書を取得できたら、クライアント証明書のハッシュ値の計算と検証をします。以下にハッシュ値の計算と検証をするリファレンス実装のコードを示します。

リスト6:

private void validateCnfValue(X509Certificate[] certs, CNF cnf) throws ServletException {

    // クライアント証明書が取得できているかの確認
    if (certs == null || certs.length == 0) {
        throw new ServletException("request has no certificates information");
    }

    // アクセストークンにクライアント証明書のハッシュ値が紐づけられているかの確認
    if (cnf.getX5t() == null) {
        throw new ServletException("IntrospectionResponse has no cnf value");
    }
    String actual = null;
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");

        // クライアント証明書をDER形式にエンコーディング
        md.update(certs[0].getEncoded());

        // ハッシュ関数SHA-256の計算
        byte[] digest = md.digest();

        // Base64URLエンコード
        actual = Base64Url.encode(digest);
    } catch (Exception e) {
        e.printStackTrace();
    }

    // 計算したハッシュ値とcnfクレームのx5t#S256ヘッダにバインドされたハッシュ値の比較
    if (!cnf.getX5t().equals(actual)) {
        throw new ServletException(
                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に準拠するために必要な実装項目の例を示します。

  • IDトークンの復号化
  • IDトークンの署名検証
  • private_key_jwtによるクライアント認証
  • tls_client_authによるクライアント認証
  • メタデータドキュメントから認可エンドポイントやトークンエンドポイントの取得

これらの実装方法はGitHubにてリファレンス実装を公開しているので、そちらをご参照ください。

著者
岡井 倫人(おかい みちと)
株式会社日立製作所 OSSソリューションセンタ
2020年7月からOSSソリューションセンタに配属。認証周りのOSSの開発、サポートに従事。 Keycloakコミュニティのコントリビュータである。

連載バックナンバー

セキュリティ技術解説
第4回

FAPI 1.0に準拠したクライアントアプリケーションと リソースサーバの作り方

2022/1/18
連載4回目となる今回は、FAPI 1.0に準拠したクライアントアプリケーションと リソースサーバの作り方を解説します。
セキュリティ技術解説
第3回

クライアントポリシーを利用したKeycloakの設定方法と、FAPIリファレンス実装の紹介

2021/12/21
連載3回目となる今回は、FAPIのリファレンス実装を利用して、FAPI 1.0の動作を確認していきます。
セキュリティ技術解説
第2回

Keycloakのクライアントポリシー(Client Policies)

2021/11/9
連載の2回目となる今回は、さまざまなセキュリティプロファイルをサポートするための仕組み、クライアントポリシーをご紹介します。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています