初版: 2021/12/5
著者: 田畑義之, 株式会社日立製作所 (GitHubアカウント: @y-tabata)
はじめに
RFC 8705で定義されているMutual-TLS Client Certificate-Bound Access Tokenという送信者制約付きのアクセストークン(sender-constrained access token)をご存じでしょうか。クライアント証明書のハッシュ値をバインドしたアクセストークンであり、Financial-grade API Security Profile 1.0 Part 2: Advancedなどの高いAPIセキュリティを要求する仕様で、実装が必須とされています。
もちろんアクセストークンなので、APIを提供するリソースサーバでそれを検証する必要がありますが、そのためのロジックをリソースサーバに実装するのは、多少骨が折れます(詳細は後述)。
そこで今回は、トークンイントロスペクションを使って、その検証ロジックを認可サーバに委譲することをKeycloakで実現してみます。
Keycloakとは、IAM(Identity and Access Management)製品で、OAuth 2.0/OpenID Connect 1.0 (OIDC)の認可サーバ(OpenID Provider)としても利用可能なOSSです。詳細は、Think ITの連載「Keycloakで実現するAPIセキュリティ」をご参照ください。
本記事は、あくまで執筆者の見解であり、日立製作所の公式なドキュメントではありません。
トークンイントロスペクションとは
OAuth 2.0(RFC 6749)に準拠する場合、クライアントはリソースサーバ上の保護されたリソースにアクセストークンを用いてアクセスし、リソースサーバはそのアクセストークンを検証する必要があります。アクセストークンの具体的な検証方法は定義されていませんが、一般的にリソースサーバと認可サーバが協調して行うものであるとされています。
よく使われるアクセストークンの検証方法の一つとして、RFC 7662で定義されたトークンイントロスペクションがあります。リソースサーバは、認可サーバのイントロスペクションエンドポイントにアクセストークンを送り、そのレスポンスとして当該トークンが現在アクティブかどうかを受け取ります。アクティブかどうかの判断基準としては、当該トークンの有効期限が切れていないか、当該トークンが無効化されていないか、などが代表的ですが、詳細は定義されておらず、認可サーバの実装に依存します。
Mutual-TLS Client Certificate-Bound Access Tokenとは
冒頭でもご説明した通り、Mutual-TLS Client Certificate-Bound Access Tokenは、RFC 8705で定義されている送信者制約付きのアクセストークン(sender-constrained access token)です。
OAuth 2.0で一般的に用いられるアクセストークンと言えば、RFC 6750で定義されているBearerアクセストークンですが、このトークンには送信者制約がついていないため、例えば攻撃者がそれを横取りすると、そのままそのアクセストークンを使って被害者のリソースにアクセスできてしまいます。
一方、送信者制約付きのアクセストークンであれば、たとえ攻撃者がそれを横取りしたとしても、簡単には被害者のリソースにアクセスできません(例えば、追加で被害者のクライアントのクライアント証明書を入手する必要があります)。そのため、Financial-grade API Security Profile 1.0 Part 2: Advancedなどのより高いAPIセキュリティを要求する仕様では、送信者制約付きのアクセストークンのサポートを必須としています。
Mutual-TLS Client Certificate-Bound Access Tokenは、そんな送信者制約付きのアクセストークンの一つで、クライアント証明書のハッシュ値をバインドしたアクセストークンです。
リソースサーバは、そのアクセストークンを検証するために、リソースにアクセスしてきたクライアントのクライアント証明書を取得し、そのハッシュ値を算出して、アクセストークンにバインドされた値と比較します。
このクライアント証明書のハッシュ値は、クライアント証明書をX.509証明書にパースし、DER形式でエンコードし、SHA-256でハッシュ化し、base64urlでエンコードして求める必要があり、リソースサーバで実装するのは多少骨が折れます。
そのため、この算出ロジックを元々持っている認可サーバに、クライアント証明書の検証をトークンイントロスペクションで委譲するというのが、今回やりたいことです。
RFC 7662では、トークンイントロスペクション時に追加のパラメータを設けたり、追加のアクセストークン検証ロジックを設けたりすることに対して特に制限はありませんので、上図のようなことを実現しても、標準仕様から逸脱することはありません。
ちなみに例えばリソースサーバとしてNGINXを使用すると、以下のような形で簡単にクライアント証明書をイントロスペクションリクエストのパラメータとして指定できます。NGINXを使ったトークンイントロスペクションの実装方法は「Think ITの記事: Keycloakのインストールと構築例」をご参考ください。
js_include oauth2.js;
map $http_authorization $access_token {
    ~^Bearer\s+(\S+)$ $1;
}
server {
    listen 443 ssl;
    ssl_certificate /etc/nginx/ssl/nginx.crt;
    ssl_certificate_key /etc/nginx/ssl/nginx.key;
    ssl_verify_client optional_no_ca; # 証明書検証をNGINX外で実施する際に"optional_no_ca"パラメータを使います。
    ssl_verify_depth 2;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    location / {
        auth_request /_oauth2_token_introspection;
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://resource-server.example.com/;
    }
    location = /_oauth2_token_introspection {
        internal;
        js_content introspectAccessToken;
    }
    location = /_oauth2_send_request {
        internal;
        proxy_method      POST;
        proxy_set_header  Content-Type "application/x-www-form-urlencoded";
        proxy_set_body    "token=$access_token&token_type_hint=access_token&client_cert=$ssl_client_escaped_cert&client_id=apigw&client_secret=secret"; # "$ssl_client_escaped_cert"からPEM形式のクライアント証明書を取得できます。
        proxy_pass        https://keycloak.example.com/auth/realms/test/protocol/openid-connect/token/introspect;
    }
}
Keycloakでトークンイントロスペクションをカスタマイズする
ここではKeycloakのバージョン15.0.2を使います。
Keycloakでは、トークンイントロスペクション実行部分がSPI(TokenIntrospectionSpi)になっていますので、当該SPIのプロバイダを実装することで簡単にトークンイントロスペクションのロジックを追加できます。既存のAccessTokenIntrospectionProviderFactoryとAccessTokenIntrospectionProviderを拡張するのが手っ取り早いかと思います。
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.AccessTokenIntrospectionProviderFactory;
import org.keycloak.protocol.oidc.TokenIntrospectionProvider;
public class OAuthMtlsAccessTokenIntrospectionProviderFactory extends AccessTokenIntrospectionProviderFactory {
    // IDに指定した値が、イントロスペクションリクエストのtoken_type_hintパラメータに指定する値になります。
    public static final String ID = "oauth_mtls";
    @Override
    public TokenIntrospectionProvider create(KeycloakSession session) {
        return new OAuthMtlsAccessTokenIntrospectionProvider(session);
    }
    @Override
    public String getId() {
        return ID;
    }
}
// importは略。
public class OAuthMtlsAccessTokenIntrospectionProvider extends AccessTokenIntrospectionProvider {
    private final KeycloakSession session;
    private final RealmModel realm;
    private static final Logger logger = Logger.getLogger(OAuthMtlsAccessTokenIntrospectionProvider.class);
    public OAuthMtlsAccessTokenIntrospectionProvider(KeycloakSession session) {
        super(session);
        this.session = session;
        this.realm = session.getContext().getRealm();
    }
    @Override
    public Response introspect(String token) {
        try {
            AccessToken accessToken = verifyAccessToken(token);
            ObjectNode tokenMetadata;
            if (accessToken != null) {
                tokenMetadata = JsonSerialization.createObjectNode(accessToken);
                tokenMetadata.put("client_id", accessToken.getIssuedFor());
                if (!tokenMetadata.has("username")) {
                    if (accessToken.getPreferredUsername() != null) {
                        tokenMetadata.put("username", accessToken.getPreferredUsername());
                    } else {
                        UserModel userModel = session.users().getUserById(realm, accessToken.getSubject());
                        if (userModel != null) {
                            tokenMetadata.put("username", userModel.getUsername());
                        }
                    }
                }
            } else {
                tokenMetadata = JsonSerialization.createObjectNode();
            }
            tokenMetadata.put("active", accessToken != null);
            return Response.ok(JsonSerialization.writeValueAsBytes(tokenMetadata)).type(MediaType.APPLICATION_JSON_TYPE)
                .build();
        } catch (Exception e) {
            throw new RuntimeException("Error creating token introspection response.", e);
        }
    }
    @Override
    protected AccessToken verifyAccessToken(String token) {
        // 既存のアクセストークン検証をまず実施。
        AccessToken accessToken = super.verifyAccessToken(token);
        // その後クライアント証明書を検証。
        if (!verifyTokenBindingWithClientCertificate(accessToken)) {
            logger.debugf("JWT check failed: %s", MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC);
            return null;
        }
        return accessToken;
    }
    @Override
    public void close() {
    }
}
verifyTokenBindingWithClientCertificateメソッドの処理は、MtlsHoKTokenUtil.javaの実装が参考になるかと思います。その際、イントロスペクションリクエストから証明書チェーンを取得する処理は、NginxProxySslClientCertificateLookup.javaの実装が参考になるかと思います。
実際に動かしてみましょう。
実装したプロバイダをKeycloakにデプロイします。デプロイに成功するとServer Info画面より以下のようにデプロイしたプロバイダが確認できるかと思います。
イントロスペクションリクエストを送ってみましょう。クライアント証明書がバインドされたアクセストークンを"token"パラメータに指定し、当該クライアント証明書を"client_cert"パラメータに指定し、"oauth_mtls"を"token_type_hint"パラメータに指定します。
POST /auth/realms/test/protocol/openid-connect/token/introspect HTTP/1.1
Host: keycloak.example.com
Accept: application/json
Content-Type: application/x-www-form-urlencoded
Authorization: Basic dGVzdDp0ZXN0
token=eyJhb...&token_type_hint=oauth_mtls&client_cert=<client-cert>
するとクライアント証明書の検証に成功し、"active": trueが返ってきます。"cnf"メンバの"x5t#S256"メンバに、アクセストークンにバインドされていたクライアント証明書のハッシュ値が格納されていることも確認できます。
{
  "exp": 1638521453,
  "iat": 1638521153,
  "auth_time": 1638520199,
  "jti": "4c596627-f52b-4fa8-b7e7-77e6f98e479e",
  "iss": "https://keycloak.example.com/auth/realms/test",
  "aud": "account",
  "sub": "ec982d54-f9c6-480c-b0ee-3963181f532b",
  "typ": "Bearer",
  "azp": "test",
  "nonce": "2ea499c9-baff-4f76-b5d4-126bdb22a539",
  "session_state": "78de285c-0352-4d1a-b493-064d221059a2",
  "preferred_username": "test",
  "email_verified": false,
  "acr": "1",
  "realm_access": {
    "roles": [
      "default-roles-test",
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "cnf": {
    "x5t#S256": "VokgOScz58DnKfqeRGt53KGbsL8pE_lSZJyofrw_thU"
  },
  "scope": "openid profile email",
  "sid": "78de285c-0352-4d1a-b493-064d221059a2",
  "client_id": "test",
  "username": "test",
  "active": true
}
おわりに
今回はトークンイントロスペクションでのクライアント証明書検証をKeycloakで実現してみました。トークンイントロスペクションを元々実装しているシステムでは、今回ご紹介した方法により、送信者制約付きのアクセストークンを導入する負荷を割と低減できるかと思いますので、参考にしてみていただければと思います。




