
# API でのトークン検証

SDK のトークン検証機能は、KeenAuth 環境が発行したアクセストークンと ID トークンを検証します。保護対象の API で使用してください。Go、TypeScript、Python の 3 つの SDK すべてに含まれており、同じ規則と同じテストベクターに従います。

## Go

```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 以降）のみを使用し、依存関係はありません。

```ts
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]"`。呼び出しは同期的で、スレッドセーフです。

```python
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

```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

```ts
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

```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

```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

```ts
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

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