FAPI Compliantなリソースサーバを作る(Nginx編)

16 min読了の目安(約14800字TECH技術記事

Digital Identity技術勉強会 #iddance Advent Calendar 2020 16日目の記事です。今日は、FAPI Compliant(準拠)なリソースサーバを作る方法について紹介したいと思います。というのも、OSSの認証・認可サーバであるKeycloakのFAPI対応[1]を少しお手伝いしていて、その中でFAPI準拠を確認するためのテストであるOpenID FoundationのConformance suiteを実行するにはリソースサーバ側も作る必要があり、そこで得た知見を今回紹介となります。

FAPIとは

FAPIそのものについて簡単に触れておきます。FAPIとは Financial-grade API の略で、金融機関のAPI公開のように高セキュリティが求められる分野で使用が想定された技術仕様です。OAuth 2.0とOpenID Connect(以下、OIDC)をベースとしており、OpenID Foundationの Financial-grade API (FAPI) WGにて仕様が策定されています。FAPI WGのページを見ると、FAPI 1.0として以下の4つの仕様について
現在策定が進められています。そのうちPart 1、Part 2がもうすぐFinalという状態ですが、今回の記事はこのPart 1、Part 2に関係するお話です。

FAPI 1.0 Part 1、Part 2について

Part 1はRead Only、Part 2はWriteも加えたAPIのためのセキュリティプロファイルになっています。Writeの方がより重要なオペレーションを伴うので、求められるセキュリティ要件が厳しくなっています。なお、WGのトップページにはまだ反映されていないようですが、2020年10月にタイトルが変わって、Part 1は Baseline、Part 2は Advanced に変更となったようです[2]

このPart 1、Part 2には認可サーバ、クライアント、リソースサーバそれぞれでやらないといけないこと、推奨されていることが書かれています。本記事は、このうちのリソースサーバをFAPI準拠で作ってみるというお話です。

リソースサーバに求められる要件

Part 1

Part 1 の 6.2.1. Protected resources provisionsに記載があります。書いてあることとしては、OAuth 2.0 Bearer Tokenを使ったアクセスを受け付けて、そのアクセストークンの検証(有効期限確認、失効されていないか、スコープが正しいか)を行うというもので、FAPIに限らず一般的なOAuth 2.0のリソースサーバで行っていることを実装すれば基本的によさそうです。検証はRFC 7662 OAuth 2.0 Token Introspectionを使う、またはJWT型アクセストークンであればJWTを検証することになります。

1点、FAPI固有の必須対応としては、レスポンスヘッダーにx-fapi-interaction-idを含めるというのがあります。やることとしては、

  • クライアントがリクエストヘッダーx-fapi-interaction-idを送って来た場合は、その値をレスポンスヘッダーx-fapi-interaction-idとして返す
  • クライアントがリクエストヘッダーx-fapi-interaction-idを送って来ない場合は、UUIDを生成してレスポンスヘッダーx-fapi-interaction-idとして返す

です。これは単純なロジックなものの、後述するリバースプロキシで実現しようとするとプロダクトによっては地味に難しかったりします。

また、リソースサーバと名指しはされていませんが、以下に記載のTLS周りも対応する必要があります(対応していないと、Conformance suiteでNGになります)。

ここに記載のとおり、TLS1.2以上に限定する必要がありますが、多くはコンフィギュレーションの範囲ですので対応は難しくはないと思います。

Part 2

こちらもPart2 の 6.2.1. Protected resources provisionsに記載があります。まず、Part 1に書いてあることは対応が必要です。その上でPart 2ではMTLSに対応する必要あり、とあります。ここで言っているMTLSとは単なる相互TLS認証という話ではなく、RFC 8705 のOAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokensをやりなさい、ということです。RFC 8705ではクライアント証明書を利用してアクセストークン発行時にその証明書との紐付けを行い、その後のリソースサーバへのAPIアクセス時に紐付け確認を行うことで、いわゆるHolder-of-Keyトークンを実現するものです。Part 1と違ってちょっと大変そうな感じがしますね。

また、Part 1と同様にTLS周りについても追加の要件があります。

ここに記載の通り、特定のCipher Suitesに限定する必要があります。これもコンフィギュレーションの範囲ですので対応は難しくはないと思います。

リソースサーバの実装方式

リソースサーバを作る場合、色々方式があるかと思いますが大体以下の4つかなと思います。

  • 専用のAPI Gatewayを利用する
  • 汎用的なリバースプロキシサーバを利用する
  • アプリケーション内でMiddlewareパターンなどを使って実装する
  • 自前でリバースプロキシやAPI Gateway相当のものを実装する

専用のAPI Gatewayを利用する

API GatewayはAmazon API GatewayGoogleのApigeeAzureのAPI Managementといったクラウド事業者が提供しているマネージドサービスもあれば、Kong3scaleのようなOSS、その他商用プロダクト(Red Hat 3scale API Managementなど)もいくつかあります。アプリケーションのAPIサーバのフロントにGatewayとしてこれらを配置することで、OAuth 2.0のリソースサーバで求められる認可処理をお任せすることができます。これらがネイティブにFAPIに対応していれば良いですが、現状多くはネイティブ対応はしておらず、拡張ポイントをうまく使って実装する必要がありそうです。例えばAmazon API Gatewayの場合は、Lambda authorizer(以前はCustom authorizer)を使ってLambdaで独自に色々と認可制御できるようになっていて、ここでFAPIで求められる要件をカスタム実装することが可能そうです。例としてAuthleteと組み合わせた方式ではありますが、Amazon API Gateway + AuthleteでFAPIを実現するチュートリアルが公開されています。Authleteの他の記事をみると、GoogleのApigee、AzureのAPI Managementでも拡張すればFAPIに対応できるようです。
なお、OSSの3scale(おそらく商用版のRed Hat 3scale API Managementも)はソースコードをチラ見した感じだとRFC 8705 OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokensに対応する処理があり、ネイティブ対応していそうでした。

汎用的なリバースプロキシサーバを利用する

代表的なリバースプロキシサーバとして、NginxApache HTTP ServerHAProxyなどがあります。Cloud Native界隈だとEnvoyが人気ですね。これらをリバースプロキシとして実際のリソースサーバのフロントに構築してAPI Gatewayが行っているような認可制御を行います。API Gateway自体、プロダクトによっては中身がNginxだったりするので、この方式はミニAPI Gateway的な感じです。

筆者が知る限りですが、現在のところ上記プロダクトはFAPIにネイティブ対応はしておりませんが、拡張モジュールやカスタマイズで対応することが可能です。実際、KeycloakのFAPI対応としてNginxとApache HTTP Serverの両方でテスト用アプリを実装し、全件テストにパスすることを確認しています。

アプリケーション内でMiddlewareパターンなどを使って実装する

最近の開発言語、フレームワークの多くはMiddlewareパターンなどをサポートしており、APIの実行前に任意の処理をフックさせて実行することが可能です。フック処理にてFAPIで求められる要件を実装することになります。
ライブラリ等を使って実装量を減らすことは可能ですが、アプリケーションがたくさんある場合それぞれに実装することになるので、あまりこの方式をとるケースはないんじゃないかなぁとは思います。

自前でリバースプロキシやAPI Gateway相当のものを実装する

最近の開発言語、フレームワークではお手軽にリバースプロキシ機能を実装することができるものがありますので、例えばGo言語で独自に認可制御を行うAPI Gatewayを自作しているケースはよく見かける気がします。そういったところでは、FAPIで求められる要件を自前で追加実装することで対応ができるかと思います。

リバースプロキシサーバ方式で実装する

実装方式を大きく4つ紹介しましたが、本記事ではリバースプロキシサーバ方式でNginxを使って具体的にどのように実装することができるか紹介したいと思います。他の方式や別プロダクト(Envoy + WebAssemblyとか面白そう)も時間があれば試したいところです。一応、Apache HTTP Serverでもmod_auth_openidcのfapiブランチを使えば対応可能ではありますが、masterブランチにはまだマージされていません。余力があれば別途記事を書きたいと思います。

なお、Conformance suiteを全件パスするために、NginxでPart 2まで対応しています。

Nginx

Nginxの場合、今回はlua-resty-openidcを使ってOAuth 2.0リソースサーバの基本機能を実装しています。lua-resty-openidcはOpenID FoundationのCertified Relying Partiesにも記載されているNginx用のOSSライブラリで、NginxをOIDCのRPやOAuth 2.0リソースサーバとして動作させることが可能になります。lua-resty-openidcはNginxをLuaスクリプトで拡張可能にするlua-nginx-moduleを前提としたLuaスクリプトのライブラリとして提供されています。ちょうど去年の認証認可技術のAdvent Calendarにて紹介記事(NginxをOpenID ConnectのRelying Partyとして実装する)が書かれていましたのでそちらも参照されるとよいでしょう。lua-resty-openidcだけでは足りない部分となるFAPIの要件部分については、今回カスタムのLuaスクリプトで実装しています。

実際のソースコード(KeycloakのFAPI対応のテスト用に作った物)はこちらです:

https://github.com/keycloak/kc-sig-fapi/tree/501d53e3a552839b0375dd9ebc6909b2d73ae7a4/api-gateway-nginx

2ファイルから構成されます。Nginxの設定ファイルとLuaスクリプトです。

  • nginx.conf (GitHub上はnginx.conf.templateという名前)
    • FAPIで必要なTLS周りの設定、MTLSの設定を追加
    • リバースプロキシとしての設定(proxy_pass)を追加
    • proxy_passの際にfapi-verify.luaファイルを呼びだして認可処理を実装
  • fapi-verify.lua
    • lua-resty-openidcを使ったリソースサーバの実装 + FAPI要件対応の追加実装

具体的な設定内容を以下、掻い摘んで紹介します。

TLS設定

TLS 1.2かつCipher SuiteもFAPIで規定されているものに絞って指定します。

nginx.conf
        ssl_certificate "/etc/x509/https/tls.crt";
        ssl_certificate_key "/etc/x509/https/tls.key";

        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout  10m;
        ssl_protocols TLSv1.2;
        ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256;
        ssl_prefer_server_ciphers on;

MTLS設定

ここは普通にNginxのMTLSを有効化するだけです。

nginx.conf
        ssl_client_certificate "/etc/x509/https/client-ca.crt";
        ssl_verify_client on;

リバースプロキシ設定

リバースプロキシされる際にフック処理が行われるよう、access_by_lua_fileで呼び出すLuaスクリプトファイルを指定します。具体的な認可処理はスクリプト内で実装しています。

nginx.conf
        location / {
                access_by_lua_file /usr/local/openresty/fapi-verify.lua;

                proxy_pass http://resource_server:3000;
        }

UUID

FAPI対応のためにUUIDを生成する必要があるので、ワーカー起動時にUUIDを生成するためのseedを初期化しておきます。なお、今回はUUIDの生成にはlua-resty-jit-uuidを使用しています。

    init_worker_by_lua_block {
        local uuid = require 'resty.jit-uuid'
        uuid.seed() -- very important!
    }

認可処理

Luaスクリプトで実装しているメインの認可処理の実装です。77行しかないので一気に全部載せちゃいます。lua-resty-openidcを使うことで、アクセストークンの検証(RFC 7662 OAuth 2.0 Token Introspectionを使ったチェックや、JWT型アクセストークンの署名検証(Keycloakの場合、JWT型アクセストークンのためこの方式も一応実装))を簡単に実装することができます。上から順番に読んでいけばいいだけなので、なんとなく分かるかと思います。なお、Conformance suiteをパスするには不要なので以下ソースコードでは実装していませんが、実際はアクセストークンに紐づくscopeの値やaudの値をトークンイントロスペクションの結果やJWT型アクセストークンから得て、アプリケーションの仕様に合わせてチェックする必要があります。また、今回は都度トークンイントロスペクションを行う実装(opts.introspection_interval = 0)にしていますが、lua-resty-openidcにその結果をキャッシュさせることもできます[3]。キャッシュさせると認可サーバとの間のやりとりが減り性能が向上しますが、認可サーバ側でアクセストークンが失効されたときに反映が遅れてしまいますので、APIの重要度に応じて性能とのトレードオフで設計する必要があります。

fapi-verify.lua
local openidc = require("resty.openidc")

openidc.set_logging(nil, { DEBUG = ngx.INFO })

local opts = {
  discovery = os.getenv("DISCOVERY_URL"),
  introspection_endpoint = os.getenv("INTROSPECTION_ENDPOINT_URL"),
  client_id = "resource-server",
  client_secret = os.getenv("CLIENT_SECRET"),
  ssl_verify = "yes",
}

local res, err = nil
if opts.introspection_endpoint ~= nil then
  opts.introspection_interval = 0

  -- call introspect for OAuth 2.0 Bearer Access Token validation
  res, err = openidc.introspect(opts)

elseif opts.discovery ~= nil then
  opts.token_signing_alg_values_expected = { "RS256" }
  opts.accept_none_alg = false
  opts.accept_unsupported_alg = false

  -- verify JWT for OAuth 2.0 Bearer Access Token validation
  res, err = openidc.bearer_jwt_verify(opts)

else
  ngx.status = 500
  ngx.log(ngx.ERR, "need to configure DISCOVERY_URL or INTROSPECTION_ENDPOINT_URL")
  ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
 
if err or not res then
  ngx.status = 401
  ngx.say(err and err or "no access_token provided")
  ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

if res.cnf == nil or res.cnf["x5t#S256"] == nil then
  ngx.status = 401
  ngx.say("no cnf.x5t#256 provided in access_token")
  ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- FAPIRW-5.2.2-5 Handling holder of key bound for access token
-- https://openid.net/specs/openid-financial-api-part-2-ID2.html#rfc.section.5.2.2
-- https://tools.ietf.org/html/rfc8705

local ssl = require "ngx.ssl"
local der_client_cert, err = ssl.cert_pem_to_der(ngx.var.ssl_client_raw_cert)
if not der_client_cert then
  ngx.log(ngx.ERR, "failed to convert client certificate from PEM to DER: ", err)
  ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

local resty_sha256 = require "resty.sha256"
local sha256 = resty_sha256:new()
sha256:update(der_client_cert)
local digest = sha256:final()

local b64 = require("ngx.base64")
local encoded = b64.encode_base64url(digest)

if encoded ~= res.cnf["x5t#S256"] then
  ngx.log(ngx.ERR, "unmatch request client certificate and cnf.x5t#256 in access_token: " .. encoded .. " != " .. res.cnf["x5t#S256"])
  ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- FAPI-R-6.2.1-11 Handling x-fapi-interaction-id
-- https://openid.net/specs/openid-financial-api-part-1-ID2.html#rfc.section.6.2.1
if ngx.var.http_x_fapi_interaction_id == nil then
  local uuid = require 'resty.jit-uuid'
  ngx.req.set_header("x-fapi-interaction-id", uuid())
end
ngx.header["x-fapi-interaction-id"] = ngx.var.http_x_fapi_interaction_id

上記実装の中で若干ややこしいのが RFC 8705 OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens への対応の箇所です。認可サーバ側でのアクセストークンの発行時に、利用されたクライアント証明書のハッシュ値(SHA256)がそのアクセストークンに紐付いて保存されています。その値はリソースサーバからはトークンイントロスペクションを行うか、JWT型アクセストークンであればそのクレームを参照すれば取り出すことができます。取り出した値と、リソースサーバへのアクセス時に利用されたクライアント証明書から再度ハッシュ値を計算して比較することで、正しいクライアント(=そのアクセストークンの発行を依頼したクライアント)からのリクエストかどうかを確認することができます。Luaで以下のように実装しています。

  1. local der_client_cert, err = ssl.cert_pem_to_der(ngx.var.ssl_client_raw_cert) にてリクエスト情報のクライアント証明書(PEM形式)をDER形式にエンコーディングする
  2. DER形式にエンコーディングできればそのハッシュ値をSHA256で算出し、さらにそれをBase64 URLエンコーディングする
  3. その値を、トークンイントロスペクションの結果またはJWT型アクセストークン内に含まれているcnf["x5t#S256"]の値と比較して一致するかどうかをチェックする

これで漏洩したアクセストークンが使われて異なるクライアントからアクセスされたとしても、ここで異常を検知して防ぐことができます。

最後に行っているのはx-fapi-interaction-idへの対応です。FAPIの要件にあったように、リクエストヘッダに存在すればそのまま設定し、なければUUIDを新規に生成して返します。Luaスクリプトであればこれは簡単に実装できます。なお、Apache HTTP Serverの場合はこれを設定レベルで実現するのは難しいため、mod_auth_openidcの作者にフィードバックしてmod_auth_openidcのfapiブランチにてモジュール内で実装してもらっています。

以上で実装は完了です。lua-resty-openidcを使ったことがある方は、実装する内容がちょっと増えただけかな〜という感じでしょうか。

Conformance suiteの実行

KeycloakのFAPI対応のテストでリソースサーバをNginxに移行[4]するというプルリクエストで紹介したNginxの設定とLuaスクリプトを利用しているのですが、ローカルでConformance suiteを実行し、全件パスすることを確認しています。以下、Conformance suiteの実行結果のキャプチャです。

なお、補足として認可サーバ側であるKeycloakは、現時点(2020/12/15時点)の最新版である11.0.3ではまだ完全にFAPI対応しておらず、次のバージョンである12.0.0(もうすぐ出るはず)で全件パスするようになります。

おわりに

というわけで、今日はFAPI準拠なリソースサーバをNginxで構成する方法について紹介しました。リソースサーバ側ならFAPI対応は意外と簡単[5]に実装できることを感じ取ってもらえたかなと思います。対応の難易度としては、認可サーバ、クライアント、リソースサーバの3つの中で多分一番簡単かと思います。なお、認可サーバは一番大変そうというのは分かりますが、アプリケーション側の担当範囲であるクライアント側も地味に面倒そうです。クライアント側の実装例もいずれ紹介できればなと思います。

脚注
  1. 元々はOSSセキュリティ技術の会というコミュニティ内で対応を進めていましたが、現在はFAPI-SIG(Special Interest Group)がKeycloak本家で立ち上がりそこで活動しています。この辺りの活動の詳細にもし興味がありましたらKeycloakのFAPI-SIGについてを参照してください。 ↩︎

  2. https://bitbucket.org/openid/fapi/issues/329/fapi-10-part-1-and-part-2-title ↩︎

  3. 0より大きな時間(分)を設定することでキャッシュできます。また、アクセストークンの有効期限も確認して有効期限内で自動的にキャッシュしてくれます。 ↩︎

  4. 元々Keycloak Gatekeeperをカスタマイズして対応していたのですが、Keycloak GatekeeperがEOLになってしまったこともあり、別の方式に変更しようということになりました。そこでNginxとApache HTTP Serverの両方を試し、最終的にはNginxにしています。 ↩︎

  5. とりあえずConformance Suiteにパスするだけなら、ですが 😛 ↩︎