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)を渡すと、検証機能が両方を適用します。

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 チャレンジのみを返します。

検証の内容

エラー

すべてのエラーには 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 では、オフラインテストキットを使用してください。このキットは、明示的なテストモードの検証機能のみが受け入れるテストトークンを発行します。本番環境の検証機能は、これらを常に拒否します。