
# テストキットでオフラインテストする

各サーバー用SDK（Go、TypeScript、Python）には、単体テストとCI用の `testing` モジュールがあります。このモジュールは、固定のテスト用鍵で署名したアクセストークンとIDトークンを発行し、ネットワークアクセスなしでそれらのトークンだけを受け入れるトークン検証器を構築します。また、イベントのサンプルペイロードを提供し、公開されたテスト用シークレットでWebhook配信に署名します。テストは、KeenAuth環境、認証情報、インターネット接続がなくても実行できます。

## 仕組み

- **固定の公開テスト用鍵。** テストトークンはES256で署名され、鍵IDは `keenauth-test-1` です。この鍵は意図的に公開されています。秘密鍵の部分は公開されたラベルから導出され、公開鍵セットはすべてのSDKに含まれています。この鍵は、ほかのどの場所でも信頼しないでください。
- **実在しない発行者。** テストトークンの発行者は `https://test.keenauth.invalid` です。`.invalid` トップレベルドメインは（RFC 2606で）予約されており、名前解決されることはないため、実際の環境がこの発行者を持つことはありません。
- **明示的なテストモード。** SDKの検証器は、コードでテストモードを有効にしたときにだけこの発行者を受け入れます。通常の方法で設定された検証器は、`.invalid` 配下のどの発行者も設定エラーとして拒否します。そのため、本番用の検証器は、誤って発行者をテスト用の発行者に設定した場合でも、テストトークンを受け入れることはありません。テスト用鍵で署名されていても、実際の発行者を含むトークンは、環境が異なる鍵を公開しているため、同様に拒否されます。
- **ネットワークアクセスなし。** テストモードでは、検証器はテスト用鍵をメモリから読み込み、何も取得しません。受け入れるのはテスト用の発行者だけです。

テストモードでは、ほかの検査が弱まることはありません。有効期限切れのトークン、誤ったオーディエンス、変更されたペイロード、`jti` の欠落、証明のないDPoPバインドトークンは、本番環境と同様に拒否されます。全一覧は[API でのトークンの検証](/docs/sdk/token-verification)を参照してください。

テストモードは、テストコードの中でのみ有効にしてください。テストトークンは誰でも署名できるため、テストモードを有効にしたサービスは偽造されたトークンを受け入れてしまいます。

## Go

パッケージは `keenauthtest` で、`net/http/httptest` と同じスタイルです。

```go
import (
	"github.com/keenauth/keenauth-go/keenauthtest"
	"github.com/keenauth/keenauth-go/verify"
)

func TestOrdersAPI(t *testing.T) {
	v, err := keenauthtest.NewVerifier(verify.Config{Audience: "https://api.acme.example"})
	if err != nil {
		t.Fatal(err)
	}
	token, err := keenauthtest.AccessToken("https://api.acme.example",
		keenauthtest.WithClaims(map[string]any{"scope": "orders:read", "org_id": "org_01m3vnbwg0fvav6zw6w7v3st0r"}))
	if err != nil {
		t.Fatal(err)
	}
	// Pass v to the handler under test and send "Authorization: Bearer " + token.
	claims, err := v.VerifyAccessToken(context.Background(), token, nil)
	if err != nil || claims.Scope != "orders:read" {
		t.Fatal(err)
	}
}
```

`keenauthtest.IDToken(clientID, ...)` はIDトークンを発行します。`WithLifetime` は有効期間を変更し（負の値を指定すると有効期限切れのトークンになります）、`WithIssuedAt` は発行時刻を設定します。`WithClaims` では `nil` の値によってクレームを削除します。パッケージを使わない場合は、`verify.Config{TestingMode: true}` を自分で設定できます。

## TypeScript

モジュールはサブパス `@keenauth/node/testing` です。

```ts
import { createTestVerifier, mintAccessToken } from "@keenauth/node/testing";

const verifier = createTestVerifier({ audience: "https://api.acme.example" });
const token = mintAccessToken("https://api.acme.example", {
  claims: { scope: "orders:read", org_id: "org_01m3vnbwg0fvav6zw6w7v3st0r" },
});

const claims = await verifier.verifyAccessToken(token);
// claims.subject, claims.scope, claims.claims.org_id
```

`mintIdToken(clientId, ...)` はIDトークンを発行します。オプションは `claims`（`null` の値はクレームを削除します）、`lifetimeSeconds` および `issuedAt` です。モジュールを使わない場合は、`testing: true` を `createVerifier` に渡せます。

## Python

モジュールは `keenauth.testing` です。検証器と同様に `verify` エクストラが必要です。

```python
from keenauth import testing

verifier = testing.verifier(audience="https://api.acme.example")
token = testing.mint_access_token(
    "https://api.acme.example",
    claims={"scope": "orders:read", "org_id": "org_01m3vnbwg0fvav6zw6w7v3st0r"},
)

claims = verifier.verify_access_token(token)
# claims.subject, claims.scope, claims.claims["org_id"]
```

`testing.mint_id_token(client_id, ...)` はIDトークンを発行します。キーワード引数は `claims`（`None` の値はクレームを削除します）、`lifetime_seconds` および `issued_at` です。モジュールを使わない場合は、`testing=True` を `VerifierConfig` に渡せます。

## 既定のクレーム

| クレーム | アクセストークン | IDトークン |
|---|---|---|
| `iss` | `https://test.keenauth.invalid` | `https://test.keenauth.invalid` |
| `sub` | `usr_01m3vnbwg0e8mbe7h7xxp6rmkc` | `usr_01m3vnbwg0e8mbe7h7xxp6rmkc` |
| `aud` | 渡したオーディエンス | 渡したクライアントID |
| `client_id` | `app_01m3vnbwg0fqba5ntanfd63s8n` | — |
| `iat`, `exp` | 現在時刻、現在時刻 + 3600秒 | 現在時刻、現在時刻 + 3600秒 |
| `jti` | ランダム | — |
| `auth_time` | — | `iat` と同じ |

アクセストークンのヘッダーには `typ: at+jwt` があります（RFC 9068）。API が読み取るクレーム（`scope`、`org_id`、`permissions`、`acr`、`amr`、`auth_time` など）を追加してください。IDトークンについては、コードがチェックする場合は `nonce` を追加してください。

## サンプルイベント

キットには、よく使われるイベントのサンプルペイロードが含まれています：`user.created`、`user.updated`、`user.deleted`、`session.created`、`session.revoked`、`organization.created` および `organization_membership.created`。これらは、KeenAuth が送信するイベントと同じエンベロープとスキーマを、既定の thin モードで使用します。識別子、状態、変更されたフィールド名のみを含み、個人データは含みません。イベントハンドラーのテストに使用してください。

- Go: `keenauthtest.Event("user.created")` はJSONを返します。`keenauthtest.EventTypes()` は種類の一覧を返します。
- TypeScript: `sampleEvent("user.created")` および `sampleEventTypes()`。
- Python: `testing.sample_event("user.created")` および `testing.event_types()`。

## Webhook のテスト

キットは、KeenAuth の Webhook 配信（Standard Webhooks v1）とまったく同じ方法でペイロードに署名します。元の本文と `webhook-id`、`webhook-timestamp` および `webhook-signature` ヘッダーを返します。ハンドラーは、本番環境で実行されるものと同じコードである SDK の Webhook 検証器で検証します。異なるのはシークレットだけです。

- **公開されたテスト用シークレット。** 既定では、キットは固定の `idp_whsec_` テスト用シークレットで署名します。その鍵は公開ラベル `KeenAuth offline test kit webhook secret (public, TEST ONLY)` から導出されます。誰でも計算できるため、実際のエンドポイントには決して設定しないでください。テストでは、このシークレットを検証器に設定するか、独自のシークレットをヘルパーに渡してください。
- **実際の配信と同じ。** `webhook-id` はイベントの `id` です（ペイロードにない場合はランダムな `msg_` ID になります）。`webhook-timestamp` は、設定しない限り現在時刻です。本文は渡したとおりにそのまま送信されます。
- **リプレイと改ざん。** 古いタイムスタンプで署名して、ハンドラーが5分のウィンドウ外の配信を拒否することをテストします。また、署名後に本文を変更して、不正な署名を拒否することをテストします。

### Node

```ts
import { Webhook, WebhookVerificationError } from "@keenauth/node/webhooks";
import { sampleEvent, signWebhook, testWebhookSecret } from "@keenauth/node/testing";

const webhook = new Webhook(testWebhookSecret()); // in production: process.env.KEENAUTH_WEBHOOK_SECRET

const delivery = signWebhook(sampleEvent("user.created"));
const event = webhook.verify(delivery.body, delivery.headers); // the parsed event

// A delivery from an hour ago is refused.
const old = signWebhook(sampleEvent("user.created"), { timestamp: Math.floor(Date.now() / 1000) - 3600 });
try {
  webhook.verify(old.body, old.headers);
} catch (e) {
  // e instanceof WebhookVerificationError, e.kind === "timestamp"
}
```

`signWebhook(payload, options)` は文字列、`Uint8Array`、またはオブジェクト（`JSON.stringify` でシリアライズ）を受け取ります。オプションは `secret`、`id` および `timestamp`（Unix秒または `Date`）です。HTTP ハンドラーをテストするには、`delivery.body` を生のリクエスト本文として、`delivery.headers` と `Content-Type: application/json` とともに送信します。

### Python

```python
import time

from keenauth import testing
from keenauth.webhooks import WebhookTimestampError, WebhookVerifier

verifier = WebhookVerifier(testing.webhook_secret())  # in production: os.environ["KEENAUTH_WEBHOOK_SECRET"]

delivery = testing.sign_webhook(testing.sample_event("user.created"))
verifier.verify(delivery.body, delivery.headers)

# A delivery from an hour ago is refused.
old = testing.sign_webhook(testing.sample_event("user.created"), timestamp=int(time.time()) - 3600)
try:
    verifier.verify(old.body, old.headers)
except WebhookTimestampError:
    pass
```

`testing.sign_webhook(payload, secret=None, msg_id=None, timestamp=None)` は bytes、文字列、または辞書（コンパクトな JSON としてシリアライズ）を受け取り、`delivery.body`（bytes）と `delivery.headers`（辞書）を返します。Flask や FastAPI のようなテストクライアントでは、`delivery.body` を生の本文として、`delivery.headers` とともに POST してください。

### Go

```go
import (
	"net/http"
	"net/http/httptest"

	"github.com/keenauth/keenauth-go/keenauthtest"
	"github.com/keenauth/keenauth-go/webhook"
)

func TestWebhookHandler(t *testing.T) {
	v, err := webhook.NewVerifier([]string{keenauthtest.WebhookSecret()}) // in production: os.Getenv("KEENAUTH_WEBHOOK_SECRET")
	if err != nil {
		t.Fatal(err)
	}
	body, err := keenauthtest.Event("user.created")
	if err != nil {
		t.Fatal(err)
	}
	delivery, err := keenauthtest.SignWebhook(body)
	if err != nil {
		t.Fatal(err)
	}
	req, err := delivery.Request("http://localhost/webhooks/keenauth")
	if err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	newWebhookHandler(v).ServeHTTP(rec, req) // your handler, which calls v.Verify(r.Header, body)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
}
```

`keenauthtest.SignWebhook(payload, ...)` は `delivery.Body` と `delivery.Header` を返します。`delivery.Request(url)` は `Content-Type: application/json` を使って POST リクエストを作成します。オプションは `WithWebhookSecret`、`WithWebhookID` および `WithWebhookTimestamp` です。

## キットが行わないこと

- KeenAuth をエミュレートするものではありません。サインインフロー、トークン発行ルール、レート制限、そのほかすべてのサーバー側の動作は、ホスト型の `development` 環境でのみ実際に動作します。リリース前に、統合テストをそこで実行してください。
- テスト用の時計はありません。時刻を制御するには、独自の時計を検証器に渡し（`Now`、`now`）、トークンの発行時刻を設定してください。
- DPoP の証明は作成しません。`cnf.jkt` クレームを追加することはできますが、その場合は証明に自分で署名する必要があります。
- Webhook を配信するものではありません。実際の配信、再試行、イベントの選択は、ホスト型の `development` 環境でのみ実際に動作します。
