テストキットでオフラインテストする
各サーバー用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 でのトークンの検証を参照してください。
テストモードは、テストコードの中でのみ有効にしてください。テストトークンは誰でも署名できるため、テストモードを有効にしたサービスは偽造されたトークンを受け入れてしまいます。
Go
パッケージは keenauthtest で、net/http/httptest と同じスタイルです。
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 です。
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 エクストラが必要です。
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
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
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
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環境でのみ実際に動作します。