mTLS — 証明書結び付けアクセストークン
mTLS(RFC 8705)は、TLS ハンドシェイク中にクライアントを認証した X.509 証明書にアクセストークンを結び付ける仕組みです。OP は証明書の SHA-256 指紋(thumbprint)を cnf.x5t#S256 として発行トークンに書き込み、リソースサーバは API 呼び出し時に提示された証明書の指紋を照合します。トークンのバイト列だけが漏れても無価値で、攻撃者は証明書 と その秘密鍵も併せて入手しなければ通せません。
mTLS は既に PKI を運用している環境で特に強みを発揮します。たとえば B2B のサービスメッシュ、オープンバンキング、内部 CA が全関係者に証明書を発行しているバックエンド API などです。結び付きが TLS 層に乗っているため、アプリケーションコードはリクエストごとに何かを署名する必要がありません。代償として、TLS 終端(リバースプロキシ、ロードバランサ)が検証済み証明書を OP まで運ぶよう設定する必要があります。
実装境界
feature.MTLS が接続するのは、証明書結び付けアクセストークン(cnf.x5t#S256)と、直接 TLS または信頼済みリバースプロキシのヘッダからの証明書取得です。tls_client_auth / self_signed_tls_client_auth は token endpoint のクライアント認証方式として広告も振り分けもされません。FAPI のクライアント認証には private_key_jwt を使い、mTLS は送信者制約レイヤとして使ってください。
このページで触れる仕様
- RFC 8705 — Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 7800 — Confirmation (
cnf) claim - RFC 5280 — X.509 PKI 証明書
- FAPI 2.0 Baseline
証明書を信頼する方法
RFC 8705 は証明書を使うクライアント認証方式を定義していますが、本ライブラリの公開 mTLS seam は送信者制約の経路です。token endpoint は tls_client_auth / self_signed_tls_client_auth を広告も振り分けも行いません。非対称クライアント認証を要求する profile では、クライアントを private_key_jwt で登録してください。
feature.MTLS を有効にすると、OP は直接 TLS ハンドシェイク、または後述する信頼済みプロキシ経路からクライアント証明書を取得し、RFC 8705 の thumbprint を計算して発行トークンに結び付けます。チェーン検証は通常 TLS 終端側の責務です。OP 自身にもチェーン検証をさせる場合は、nil ではない公開 pool を渡します:
import "crypto/x509"
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
op.WithFeature(feature.MTLS),
op.WithMTLSRootCAs(pool),WithMTLSRootCAs(pool *x509.CertPool) は任意の公開 option です。nil ではない pool は、ハンドシェイク証明書と WithMTLSProxy で選ばれた証明書の両方に適用され、client authentication 用のチェーン検証が必要になります。nil は設定エラーです。何も信頼しない場合は空の x509.NewCertPool() を渡してください。直接 TLS スタックまたは信頼済みプロキシが外部クライアントのチェーンを検証済みなら、この option は省略します。
Confirmation claim — cnf.x5t#S256
OP は mTLS 認証されたクライアントにトークンを発行する際、DER エンコードした証明書の SHA-256 ハッシュ値(RFC 8705 §3)を計算し、アクセストークンに cnf.x5t#S256 として書き込みます。以後このアクセストークンを使うリクエストは 同じ証明書 で TLS 接続を確立する必要があり、リソースサーバは観測した証明書をハッシュして cnf.x5t#S256 と比較します。
cnf 自体は DPoP と共通の仕組み(RFC 7800)ですが、メンバ名 が異なります — DPoP は jkt、mTLS は x5t#S256。1 つのトークンに両方のメンバを持たせることもできます。両方の結び付きがある場合、利用側 endpoint は conjunction(AND)として検証します。つまり DPoP proof は jkt に、提示証明書は x5t#S256 に一致しなければなりません。本ライブラリは /userinfo でこの conjunction を適用し、device-code と CIBA の grant binding でも記録されたすべての方式を要求します。
なぜ証明書全体ではなく thumbprint なのか
DPoP の JWK thumbprint と同じ理由です。固定長のハッシュ値は再エンコードを跨いでも安定し、比較も安価で、JWT 内に十分収まる短さです。SHA-256 は RFC 8705 §3 が固定で指定しており、交渉の余地はありません。
リバースプロキシ構成
本番では OP が自前で TLS を終端することはほぼありません。前段の nginx / envoy / AWS ALB / クラウド LB が TLS を復号し、OP には平文 HTTP で渡します。OP に届いた時点でクライアント証明書はすでに接続から失われているため、プロキシが HTTP ヘッダ(X-SSL-Cert、X-Forwarded-Client-Cert など)で前送りする必要があります。
OP は どのヘッダから読むか と どの IP 範囲がそのヘッダを設定してよいか の両方を知る必要があります。後者を抜くと、インターネット側の任意のクライアントが偽造ヘッダを送って、認証済みクライアントになりすませてしまいます。
op.WithMTLSProxy("X-SSL-Cert", []string{"10.0.0.0/8"})引数はいずれも必須です(op/options_fapi_proxy.go):
headerNameが空文字なら設定エラー。ヘッダパスを無効化したい場合はオプション自体を渡さないでください。trustedCIDRsが空 slice なら構築時に拒否されます。設定ミスで許可リストが黙って広がる経路を塞ぐためです。
直接経路では http.Request.TLS.PeerCertificates を読みます。プロキシ経路はリクエストの RemoteAddr が設定済み trusted CIDR 内にある場合だけ参照され、範囲外からのヘッダは無視されて fail closed になります。信頼済みプロキシから来たリクエストでは、前送りされた証明書が正本であり、プロキシから OP までの TLS hop にある証明書より優先されます。プロキシの transport 証明書は OAuth クライアント証明書として扱いません。
op.MTLSProxy は公開設定型です。op.MTLSProxyConfig(provider) は記録済みの HeaderName と fresh な TrustedProxies slice を返し、option が無ければ zero value を返します。組み込み側の edge code はこの公開 projection を使ってヘッダ除去ポリシーを OP と揃えられ、internal verifier の import は必要ありません。
実装例
mTLS 送信者制約の最小構成:
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/feature"
)
op.New(
/* 必須オプション */
op.WithFeature(feature.MTLS),
op.WithMTLSProxy("X-SSL-Cert", []string{"10.0.0.0/8"}),
)OP が自前で TLS を終端する環境(テスト、シングルテナント on-prem 等)では、WithMTLSProxy 行は省略可能です — ライブラリは http.Request.TLS.PeerCertificates から直接証明書を読みます。OP 自身にもチェーン再検証をさせる場合は、CA を追加した x509.NewCertPool() と WithMTLSRootCAs を渡してください。
op.WithProfile(profile.FAPI2Baseline) は [DPoP, MTLS] に対する RequiredAnyOf を課します。どちらも明示しなければ、プロファイルは DPoP を既定メンバーとして選びます。mTLS の送信者制約を使う構成では feature.MTLS を明示してください。その場合は mTLS が制約を満たすため DPoP 既定は追加されません。クライアントの token endpoint 認証には private_key_jwt を使います。
落とし穴
- TLS 終端が証明書を正しく前送りすること。 プロキシごとにヘッダ名とエンコード(DER / PEM / URL エンコード PEM)が違います。両端で形式と
WithMTLSProxyのヘッダ名を固定してください。 - 証明書の更新で binding が変わる。 トークンは発行時の leaf 証明書に結び付いています。証明書を更新したらトークンも再発行してください。新しい証明書では古いトークンの
cnf.x5t#S256を満たせません。 - mTLS クライアント認証を token endpoint method として設定しない。 mTLS は送信者制約レイヤです。FAPI 構成のクライアント認証には
private_key_jwtを使ってください。 - 多段プロキシでの
RemoteAddrの意味。 OP の前にプロキシが 2 段ある場合、RemoteAddrに乗るのは 直前の プロキシの IP のみです。その IP がtrustedCIDRsに入っている必要があります。さらに外側のプロキシはヘッダ許可リストの対象外です(OP が直接見ないため)。
mTLS が向いているケース
- 既存 PKI を持つバックエンドサービス — 全サービスが内部 CA 発行のクライアント証明書を既に持っている環境では、新しい鍵管理面を増やさずに mTLS を導入できます。
- オープンバンキング・B2B サービスメッシュ — 規制やパートナー要件として、ネットワーク層で mTLS が既に必須になっているケースが多くあります。RFC 8705 はその上にトークン結び付けを乗せるだけです。
- TLS 終端をすでに運用している運用チーム —
WithMTLSProxyの設定は一度きりの作業で、既存の nginx / envoy 設定の隣に自然に収まります。 - リクエストごとの署名コストを払いたくない制約クライアント — 結び付きが TLS 層にあるため、アプリ側は API 呼び出しごとに新規署名を作る必要がありません。
mTLS が向かないケース
- ブラウザ — 現在のブラウザはクライアント証明書を提示する手段が乏しく、SPA で mTLS を実用化するのは現実的ではありません。代わりに DPoP を使ってください。
- モバイルアプリ — 多くのプラットフォームはクライアント証明書をサポートしますが、プロビジョニングと更新の UX が芳しくありません。DPoP のリクエスト毎署名のほうがモバイル鍵ストアと相性が良いことが多いです。
- PKI が無い環境 — 単にクライアント証明書を発行するためだけに内部 CA を立ち上げるのは重い投資です。これから始めるなら、DPoP のほうが証明書ロジスティクス無しで送信者制約を導入できます。
- 異種混在環境 — SPA とバックエンドが混ざる環境では結局両方を運用することになりがちです。discovery に両方を出し、クライアントごとに使えるほうを選ばせる構成が現実的です。
次に読む
- DPoP (RFC 9449) — もう一方の送信者制約方式。クライアント保有鍵に結び付けます。
- 送信者制約 — 選定ガイド — 比較表と使い分けの指針。
- 使い方: FAPI 2.0 Baseline —
private_key_jwtクライアント認証と送信者制約を含む完全な組み込み例。 - 設計判断 — 仕様間トレードオフの整理。