コンテンツにスキップ

DPoP検証

DPoP(Demonstrating Proof of Possession、RFC 9449)は、アクセストークンをクライアントの暗号鍵ペアにバインドするメカニズムです。標準的なBearerトークンは、所持している人なら誰でも使用できます。DPoPバインドトークンは、対応する秘密鍵を保持しているクライアントのみが使用できます。

クライアントは各リクエストにDPoPプルーフ(署名されたJWT)を含めます。リソースサーバーは以下を検証します:

  1. プルーフがアクセストークンにバインドされた鍵で署名されていること
  2. プルーフが現在のHTTPメソッドとURLに一致すること
  3. プルーフがリプレイされていないこと

@authrim/serverを使用するリソースサーバーとして、DPoPに関する責任は以下の通りです:

責任SDKが処理アプリケーションが処理
DPoPプルーフJWTの解析はい
プルーフ署名の検証はい
typalgjwkヘッダーの検証はい
htmhtuiatクレームの検証はい
ath(アクセストークンハッシュ)の検証はい
JWK Thumbprintバインディング(cnf.jkt)の検証はい
JWK内の秘密鍵パラメータの拒否はい
jtiリプレイ保護はい
DPoP nonce管理はい
const dpopResult = await authrim.validateDPoP(proof, options);
パラメータ説明
proofstringDPoPリクエストヘッダーからの生のDPoPプルーフJWT
optionsDPoPValidationOptions検証パラメータ
interface DPoPValidationOptions {
method: string;
uri: string;
accessToken?: string;
expectedThumbprint?: string;
expectedNonce?: string;
maxAge?: number;
clockTolerance?: number;
}
オプションデフォルト説明
methodstring現在のリクエストのHTTPメソッド(例: 'GET''POST'
uristring現在のリクエストURI。SDKは htu 比較のためにスキーム + ホスト + パスへ正規化します
accessTokenstringproofのathクレーム検証に使うアクセストークン
expectedThumbprintstringトークンのcnf.jktクレームからの期待されるJWK Thumbprint
expectedNoncestringプルーフに含まれている必要があるサーバー発行のnonce
maxAgenumber60プルーフの最大有効期間(秒)(iatに基づく)
clockTolerancenumber60クロックスキュー許容値(秒)

現在のSDKが受け入れるDPoP proof署名アルゴリズムは ES256PS256EdDSA です。

flowchart TD
    A["DPoPプルーフを受信"] --> B["JWTヘッダーを解析<br>(typ: dpop+jwt)"]
    B --> C["アルゴリズムチェック<br>(none拒否, 許可リスト確認)"]
    C --> D["公開鍵を抽出<br>(秘密鍵パラメータを拒否)"]
    D --> E["プルーフ署名を検証"]
    E --> F["htmを検証<br>(HTTPメソッドと一致)"]
    F --> G["htuを検証<br>(リクエストURLと一致)"]
    G --> H["iatを検証<br>(maxAge以内)"]
    H --> I{"athあり?"}
    I -->|はい| J["アクセストークンハッシュを検証"]
    I -->|いいえ| K["athチェックをスキップ"]
    J --> L{"expectedThumbprint?"}
    K --> L
    L -->|はい| M["JWK Thumbprintを検証<br>(タイミングセーフ)"]
    L -->|いいえ| N["バインディングチェックをスキップ"]
    M --> O["DPoP結果を返却"]
    N --> O

DPoPバインドリクエストを検証する典型的なフローを示します:

import { createAuthrimServer } from '@authrim/server';
const authrim = createAuthrimServer({
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
await authrim.init();
async function handleRequest(req) {
// 1. Extract tokens from headers
const authHeader = req.headers['authorization'];
const dpopProof = req.headers['dpop'];
if (!authHeader?.startsWith('DPoP ')) {
throw new Error('Missing DPoP authorization');
}
const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
// 2. Validate the access token
const tokenResult = await authrim.validateToken(accessToken);
if (tokenResult.error) {
throw new Error(tokenResult.error.message);
}
// 3. If token is DPoP-bound, validate the proof
if (tokenResult.data.tokenType === 'DPoP' && dpopProof) {
const dpopResult = await authrim.validateDPoP(dpopProof, {
method: req.method,
uri: `${req.protocol}://${req.hostname}${req.path}`,
accessToken,
expectedThumbprint: tokenResult.data.claims.cnf?.jkt,
});
if (!dpopResult.valid) {
throw new Error(dpopResult.errorMessage ?? 'Invalid DPoP proof');
}
}
return tokenResult.data;
}

JWK Thumbprintは公開鍵の正規形式のSHA-256ハッシュです。アクセストークン(cnf.jktクレーム経由)をDPoPプルーフの署名鍵にバインドします。

SDKはThumbprint操作用のユーティリティ関数を提供しています:

import {
calculateJwkThumbprint,
verifyJwkThumbprint,
} from '@authrim/server';
// Calculate a thumbprint from a JWK
const thumbprint = await calculateJwkThumbprint(publicJwk);
// Verify a thumbprint matches a JWK (timing-safe)
const isValid = await verifyJwkThumbprint(publicJwk, expectedThumbprint);

Thumbprintの計算はRFC 7638に従います:

  1. 鍵タイプに必要なメンバーを抽出(ECの場合はktycrvxy、RSAの場合はktyen
  2. ソートされたキーと空白なしのJSONオブジェクトとしてシリアライズ
  3. SHA-256ハッシュを計算
  4. Base64urlとしてエンコード

validateDPoP() は検証結果とproof鍵のthumbprintを返します。jtiは戻り値に含まれないため、replay cacheへ書き込む前にアプリケーション側でproof payloadから取り出してください。

// Example: DPoP jti replay protection with Redis
const DPOP_JTI_TTL = 60; // maxAge と揃える
async function checkDPoPReplay(jti: string): Promise<boolean> {
const key = `dpop:jti:${jti}`;
// SET NX returns 'OK' only if the key did not exist
const result = await redis.set(key, '1', 'EX', DPOP_JTI_TTL, 'NX');
return result === 'OK'; // true = new jti, false = replay
}
// Usage in your request handler
async function handleProtectedRequest(req) {
const tokenResult = await authrim.validateToken(accessToken);
if (tokenResult.error) {
throw new Error(tokenResult.error.message);
}
const jti = extractJtiFromProof(dpopProof);
const dpopResult = await authrim.validateDPoP(dpopProof, { /* ... */ });
if (!dpopResult.valid) {
throw new Error(dpopResult.errorMessage ?? 'Invalid DPoP proof');
}
// Check for jti replay
const isNew = await checkDPoPReplay(jti);
if (!isNew) {
throw new Error('DPoP proof replay detected');
}
// Proceed with the request
}

jti追跡に関する重要な考慮事項:

  • TTLはmaxAgeに一致させる — キャッシュエントリのTTLをmaxAgeと同じ値(デフォルト: 60秒)に設定します。エントリは自動的に期限切れになります。
  • 分散キャッシュが必要 — マルチサーバーデプロイメントでは、すべてのサーバーが同じjti値を参照できるように共有キャッシュ(Redis、Memcached)を使用します。
  • 高カーディナリティ — 各DPoPプルーフは一意のjtiを持ちます。高トラフィックのAPIでは、キャッシュがボリュームを処理できることを確認してください。

認可サーバーは、事前生成されたプルーフを防止するためにDPoP-Nonceを発行する場合があります。リソースサーバーでnonceを適用する必要がある場合:

// 1. Generate and store a nonce
const nonce = generateSecureNonce();
storeNonce(nonce);
// 2. Return the nonce to the client in the response header
res.setHeader('DPoP-Nonce', nonce);
// 3. On subsequent requests, verify the nonce
const dpopResult = await authrim.validateDPoP(dpopProof, {
method: req.method,
uri: requestUrl,
expectedNonce: getStoredNonce(),
});

クライアントが期待されるnonceなしでリクエストを送信した場合、以下で応答します:

res.status(401).json({ error: 'use_dpop_nonce' });
res.setHeader('DPoP-Nonce', newNonce);

クライアントはnonceを含む新しいDPoPプルーフでリクエストを再試行する必要があります。

validateDPoP() は代表的なproof検証失敗を dpopResult.errorCode に以下のコードで返します。

コード説明
dpop_proof_invalidmalformed proof、サポート外アルゴリズム、必須claim欠落、proof JWK内の秘密鍵パラメータなど、一般的なDPoP proof検証失敗
dpop_proof_signature_invalidproof署名検証が失敗
dpop_binding_mismatchproof鍵がトークンのcnf.jktと一致しない
dpop_iat_expiredproofのiatmaxAgeとclock toleranceを超過
dpop_method_mismatchproofのhtmがリクエストメソッドと一致しない
dpop_uri_mismatchproofのhtuがリクエストURIと一致しない
dpop_ath_mismatchproofのathがアクセストークンと一致しない
dpop_nonce_requiredproof nonceが期待されるnonceと一致しない