Skip to content

使い方 — DPoP nonce フロー

DPoP とは何か、nonce とは何か

DPoP("Demonstrating Proof of Possession", RFC 9449)は、アクセストークンをクライアントが保持する鍵に紐づける仕組みです。クライアントは API 呼び出しのたびに、その鍵で署名した新しい JWT を DPoP: ヘッダで提示し、「私はこのトークンを発行されたクライアントと同一です」と証明します。漏洩した DPoP-bound トークンは、鍵を持たない攻撃者にとって無価値です。

nonce は RFC 9449 §8 / §9 が追加する補強策です。これがないと、クライアントは事前に DPoP proof をいくつも作って手元に置けてしまい、クライアントを一時的に侵害した攻撃者がそれをまとめて持ち出して後から再利用できる、という穴が残ります。nonce はその穴を塞ぎます: OP がサーバ側で生成した最新 nonce を発行し、次の DPoP proof には 必ずその nonce を含める ことを要求します。事前計算した proof は即座に無効化されます。

このページで触れる仕様
用語の補足
  • DPoP proof — クライアントがリクエスト毎に署名する小さな JWT。「アクセストークンがバインドされた秘密鍵を、いまも自分が保持している」ことを示します。基本は 送信者制約 を参照。
  • 事前計算 proof 攻撃 — クライアントの端末を一時的に侵害した攻撃者が、有効な proof をまとめて持ち出して後から再利用するシナリオ。nonce が無いと、proof は iat 窓が許す限り有効なままです。

短くまとめると、nonce フローは次の 2 種の攻撃を遮ります:

  • 事前計算 proof — proof を傍受しても、次の nonce を知らない攻撃者は再利用できません。
  • stage-and-fire — オフラインで仕込んだ長寿命 proof は、OP が nonce をローテーションすると無効化されます。

ソース: examples/51-dpop-nonce

フロー

最初に一度弾き、以降は毎回 nonce を載せる
OP は最初の proof を use_dpop_nonce で拒否し、ヘッダで nonce を返します。クライアントはその nonce を入れて proof を作り直し、成功します。以降の応答は毎回次の nonce を運ぶため、各 proof は OP が選んだ値に固定されます。RP / クライアントpriv_dpop を保有OPgo-oidc-provider1POST /token · DPoP: <proof>まだ nonce が無い — 事前に知る手段がない2400 use_dpop_nonceDPoP-Nonce: nonce-13proof を作り直すnonce = nonce-1 を入れる4POST /token · DPoP: <proof, nonce-1>5200 · DPoP 結び付き付きの access_tokenDPoP-Nonce: nonce-2 — 次はこれを使う6GET /userinfo · DPoP: <proof, nonce-2>7200 · { user claims }DPoP-Nonce: nonce-3 — 以降も同様
最初の拒否は特別扱いすべき失敗ではなく、ハンドシェイクそのものです。use_dpop_nonce を通常のエラーとして扱うクライアントは再試行を繰り返しますが、ヘッダを読むクライアントは次の 1 回で成功します。

このハンドシェイクは最初の 1 回だけです。以降の呼び出しは常に最新の nonce を運びます。OP は応答ごとに DPoP-Nonce ヘッダを返し、クライアントはその値を次の proof の nonce claim に入れます。

実装

ライブラリは in-memory 参考実装を同梱しています。シングルプロセス用、HA セーフではありませんが、開発と小規模 deploy には十分:

go
import "github.com/libraz/go-oidc-provider/op"

src, err := op.NewInMemoryDPoPNonceSource(ctx, 5*time.Minute)
if err != nil { /* ... */ }

op.New(
  /* 必須オプション */
  op.WithFeature(feature.DPoP),
  op.WithDPoPNonceSource(src),
)

ローテーション間隔(上の 5*time.Minute)は「現行」nonce が切り替わる頻度です。OP は現行値と直前の値の両方を受理するので、ローテーション境界でリクエストが競合してもハード失敗にはなりません。

複数インスタンス構成

プロセスローカルな nonce ソースはレプリカを跨げません — インスタンス B はインスタンス A が出した nonce を知りません。本番 HA 構成では、独自 DPoPNonceSource の裏に共有ストア(Redis)を置きます。Redis nonce ソースをライブラリに同梱しないのは意図的です。オプション群(TTL、ローテーション周期、ローテーション境界の取りこぼし許容度)が運用ごとに異なりすぎるためです。

OP が nonce を要求するエンドポイント

Endpointnonce 必須?設定箇所
/tokenDPoP proof が提示され、DPoPNonceSource を設定している場合に必須op.WithDPoPNonceSource
/userinfoDPoP proof が提示され、DPoPNonceSource を設定している場合に必須同上
/parDPoP proof が提示され、DPoPNonceSource を設定している場合に必須同上
/device_authorizationDPoP proof が提示され、DPoPNonceSource を設定している場合に必須同上
/bc-authorize(CIBA)DPoP proof が提示され、DPoPNonceSource を設定している場合に必須同上

/par/token は nonce の発行・要求を対称に行うので、PAR(プッシュ型認可リクエスト)を使う SPA も /token と同じ nonce リトライループを /par 呼び出しで回すことになります。

DPoP ヘッダが提示された場合、表中のすべての endpoint はその値を 1 つだけ受け付けます。複数値は、先頭が空で後続に有効な proof がある場合も含め、不正として拒否されます。OP が後続の値だけを選んだり、黙って proof なしの経路として扱ったりすることはありません。

FAPI 2.0 Message Signing は nonce を強制し、Baseline では許可します。ライブラリは仕様に追従するので、プロファイルを切り替えれば既定も切り替わります。

動作確認

sh
# nonce なしの最初の呼び出し
curl -i -X POST -H "DPoP: <nonce なしの proof>" \
  -d 'grant_type=authorization_code&code=...' \
  http://localhost:8080/oidc/token | head -20
# HTTP/1.1 400 Bad Request
# DPoP-Nonce: <fresh-nonce>
# {"error":"use_dpop_nonce", ...}

DPoP-Nonce の値を次の proof の nonce claim に入れて再試行します。

続きはこちら