API でのトークン検証
SDK のトークン検証機能は、KeenAuth 環境が発行したアクセストークンと ID トークンを検証します。保護対象の API で使用してください。Go、TypeScript、Python の 3 つの SDK すべてに含まれており、同じ規則と同じテストベクターに従います。
Go
import "github.com/keenauth/keenauth-go/verify"
v, err := verify.New(verify.Config{
Issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
Audience: "https://api.acme.example", // the identifier of your API
})
if err != nil {
return err
}
// In your HTTP handler:
claims, err := v.VerifyAccessToken(r.Context(), token, &verify.DPoPInput{
Proof: r.Header.Get("DPoP"),
Method: r.Method,
URL: "https://api.acme.example" + r.URL.Path,
})
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// claims.Subject, claims.ClientID, claims.Scope
ID トークンの場合は、設定で ClientID を指定し、v.VerifyIDToken(ctx, idToken, verify.IDTokenOptions{Nonce: nonce}) を呼び出します。
TypeScript
TypeScript の検証機能は、プラットフォームの WebCrypto と fetch(Node.js 22 以降)のみを使用し、依存関係はありません。
import { createVerifier, VerifyError } from "@keenauth/node";
const verifier = createVerifier({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
audience: "https://api.acme.example", // the identifier of your API
});
// In your HTTP handler:
try {
const claims = await verifier.verifyAccessToken(token, {
proof: req.get("DPoP") ?? "",
method: req.method,
url: "https://api.acme.example" + req.path,
});
// claims.subject, claims.clientId, claims.scope
} catch (e) {
if (!(e instanceof VerifyError)) throw e;
res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).send("unauthorized");
}
ID トークンの場合は、設定で clientId を指定し、verifier.verifyIdToken(idToken, { nonce }) を呼び出します。
Python
Python の検証機能には cryptography パッケージが必要です。これは verify エクストラに含まれています: pip install "keenauth-python[verify]"。呼び出しは同期的で、スレッドセーフです。
from keenauth.verify import DPoPInput, Verifier, VerifierConfig, VerifyError
verifier = Verifier(VerifierConfig(
issuer="https://acme.eu.keenauth.app", # your environment's issuer, exactly
audience="https://api.acme.example", # the identifier of your API
))
# In your HTTP handler:
try:
claims = verifier.verify_access_token(
token,
DPoPInput(
proof=request.headers.get("DPoP", ""),
method=request.method,
url="https://api.acme.example" + request.path,
),
)
# claims.subject, claims.client_id, claims.scope
except VerifyError:
return Response("unauthorized", status=401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
ID トークンの場合は、設定で client_id を指定し、verifier.verify_id_token(id_token, nonce=nonce) を呼び出します。
ステップアップ(RFC 9470)
機密性の高い操作を保護するには、呼び出しごとに、より強力または新しいサインインを要求します。許容する認証コンテキストクラス参照(acr)と、サインインの最大経過時間(auth_time)を渡します。以下の dpop は、上記の例で使用した DPoP 入力です(DPoP を使用しない場合は nil/undefined/None)。
Go
age := 15 * time.Minute
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
AcceptableACR: []string{"urn:idp:acr:mfa", "urn:idp:acr:phr"},
MaxAuthAge: &age,
})
if challenge, ok := verify.StepUpChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "step-up required", http.StatusUnauthorized)
return
}
TypeScript
import { createVerifier, stepUpChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, {
acceptableAcr: ["urn:idp:acr:mfa", "urn:idp:acr:phr"],
maxAuthAgeSeconds: 900,
});
} catch (e) {
const challenge = stepUpChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("step-up required");
}
Python
from datetime import timedelta
from keenauth.verify import VerifyError, step_up_challenge
try:
claims = verifier.verify_access_token(
token,
dpop,
acceptable_acr=["urn:idp:acr:mfa", "urn:idp:acr:phr"],
max_auth_age=timedelta(minutes=15),
)
except VerifyError as err:
challenge = step_up_challenge(err)
if challenge is None:
raise
return Response("step-up required", status=401, headers={"WWW-Authenticate": challenge})
その他のすべてのチェックの後に検証が行われるため、無効なトークンは常に invalid_token になります。有効なトークンでも要件を満たさない場合は、コード insufficient_user_authentication が返され、エラーには必要な acr_values と max_age(設定したものだけ)が含まれます。StepUpChallenge(Go)、stepUpChallenge(TypeScript)、step_up_challenge(Python)は、これを RFC 9470 §3 の WWW-Authenticate ヘッダー値に変換します。例: Bearer error="insufficient_user_authentication", error_description="authentication too old", max_age="900"。このチャレンジを理解するクライアントは、それらの acr_values と max_age を使って新しい認可リクエストを開始します。acr は、いずれかの値と完全に一致する必要があります(大文字と小文字を区別)。最大経過時間を設定する場合は auth_time が必須であり、時計のずれの許容範囲は ID トークンと同様に適用されます。urn:idp:acr:1fa、urn:idp:acr:mfa、urn:idp:acr:phr の各値は、環境が acr_values_supported で公開している値です。
DPoP と Authorization スキーム(RFC 9449)
DPoP にバインドされたトークン(cnf.jkt)は、そのクライアントの秘密鍵がなければ使用できないため、盗まれたコピーはリプレイできません。これを維持するための 2 つの規則があり、リクエストの Authorization ヘッダーのスキーム(Bearer または DPoP)を渡すと、検証機能が両方を適用します。
Bearerスキームで送信された DPoP にバインドされたトークンは、有効な証明が添付されていてもinvalid_tokenで拒否されます(RFC 9449 §7.2)。DPoPスキームで送信されたトークンは、DPoP にバインドされ、かつ有効な証明が添付されている必要があります(RFC 9449 §7.1)。
API で送信者制約付きのトークンのみを受け入れるには(機密性の高い API では推奨)、「DPoP を要求」も設定します。DPoP にバインドされていないトークンには、コード dpop_required が返されます。DPoP チャレンジヘルパーは、任意の 401 エラーを RFC 9449 §7.1 の WWW-Authenticate 値に変換します。例: DPoP error="invalid_token", error_description="a DPoP-bound access token is required", algs="ES256 EdDSA PS256 RS256"。
Go
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
Scheme: scheme, // "Bearer" or "DPoP", from the Authorization header
RequireDPoP: true,
})
if challenge, ok := verify.DPoPChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
TypeScript
import { dpopChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, { scheme, requireDPoP: true });
} catch (e) {
const challenge = dpopChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("unauthorized");
}
Python
from keenauth.verify import VerifyError, dpop_challenge
try:
claims = verifier.verify_access_token(token, dpop, scheme=scheme, require_dpop=True)
except VerifyError as err:
challenge = dpop_challenge(err)
if challenge is None:
raise
return Response("unauthorized", status=401, headers={"WWW-Authenticate": challenge})
リソースサーバーのミドルウェア(Go の protect、TypeScript の @keenauth/node/middleware、Python の keenauth.middleware)は、スキームを自動で読み取ります。その「DPoP を要求」設定(API ごと、またはルートごと)では、Bearer リクエストに対して 401 と DPoP チャレンジのみを返します。
検証の内容
- 発行者と鍵: トークンの
issは、設定された発行者と一致する必要があります。鍵は、その発行者のディスカバリードキュメントにあるjwks_uriからのみ取得され、kidはその鍵セット内でのみ検索されます。別の環境の鍵で署名されたトークンは拒否されます。 - アルゴリズム: 使用できるのは RS256、PS256、ES256、EdDSA のみです。鍵の
algはトークンのalgと一致する必要があります。noneと HMAC アルゴリズムは常に拒否されます。 - アクセストークン:
typはat+jwt(RFC 9068)である必要があり、audは設定したオーディエンスを含む必要があります。また、subとclient_idが存在する必要があります。 - ID トークン:
audはクライアント ID を含む必要があり、azpが存在する場合はクライアント ID と一致する必要があります。nonceとmax_ageは、渡された場合に検証されます。 - 時刻:
expは必須です。nbfとiatは未来の時刻であってはなりません。最大 60 秒の時計のずれが許容されます。 - DPoP: トークンが鍵にバインドされている(
cnf.jkt)場合、有効な DPoP 証明(RFC 9449)が必要です。再利用された証明は拒否されます。スキームオプションを使用すると、バインドされたトークンがベアラートークンとして受け入れられることはありません。「DPoP を要求」を設定すると、バインドされたトークンのみが受け入れられます。 - 鍵のキャッシュ: 鍵は、サーバーの
max-age(既定では 300 秒、最大 600 秒)の間キャッシュされます。未知のkidが検出された場合の再取得は、1 分あたり最大 1 回です。
エラー
すべてのエラーには Code があります。エラーメッセージには、トークンやそのクレームが含まれることはありません。
| Code | 意味 | 応答 |
|---|---|---|
invalid_token |
トークンが無効、期限切れ、またはこの API 向けではありません。 | 401 |
jwks_unavailable |
発行者の鍵を取得できず、キャッシュもありません。 | 503 |
dpop_invalid |
DPoP 証明がないか、無効です。 | DPoP チャレンジヘルパーの WWW-Authenticate を含む 401 |
dpop_required |
DPoP が必須ですが、トークンが DPoP にバインドされていません。 | DPoP チャレンジヘルパーの WWW-Authenticate を含む 401 |
insufficient_user_authentication |
トークンは有効ですが、その acr または auth_time が要件を満たしていません(RFC 9470)。 |
ステップアップヘルパーの WWW-Authenticate を含む 401 |
config_invalid |
検証機能の設定に誤りがあります。 | 設定を修正してください。 |
他の JWT ライブラリを使用する場合
同じチェックリストを適用してください。発行者をそのディスカバリードキュメントに結び付け、鍵はその jwks_uri からのみ取得し、鍵の alg がトークンの alg と一致することを要求し、none と HMAC アルゴリズムを拒否してください。
オフラインでのテスト
単体テストや CI では、オフラインテストキットを使用してください。このキットは、明示的なテストモードの検証機能のみが受け入れるテストトークンを発行します。本番環境の検証機能は、これらを常に拒否します。