
# Testing offline with the test kit

Each server SDK (Go, TypeScript and Python) has a `testing` module for unit tests and CI. It mints access tokens and ID tokens signed with a fixed test key, builds a token verifier that accepts exactly those tokens without any network access, gives you sample event payloads, and signs webhook deliveries with a public test secret. Your tests run without a KeenAuth environment, without credentials and without the internet.

## How it works

- **A fixed, public test key.** Test tokens are signed with ES256 and the key ID `keenauth-test-1`. The key is deliberately public: its private part is derived from a published label, and the public key set is in every SDK. Never trust this key anywhere else.
- **An issuer that cannot exist.** Test tokens carry the issuer `https://test.keenauth.invalid`. The `.invalid` top-level domain is reserved (RFC 2606) and never resolves, so no real environment can have this issuer.
- **Explicit testing mode.** The SDK verifier accepts this issuer only when testing mode is turned on in code. A verifier configured the normal way refuses any issuer under `.invalid` as a configuration error, so a production verifier can never accept a test token, even if its issuer were set to the test issuer by mistake. A token signed with the test key but carrying your real issuer is rejected too, because your environment publishes different keys.
- **No network.** In testing mode the verifier takes the test key from memory and never fetches anything. It accepts only the test issuer.

Testing mode does not weaken the other checks: expired tokens, a wrong audience, a changed payload, a missing `jti` and a DPoP-bound token without a proof are rejected exactly as in production. See [Verifying tokens in your API](/docs/sdk/token-verification) for the full list.

Keep testing mode in test code only. Anyone can sign test tokens, so a service that enabled it would accept forged tokens.

## Go

The package is `keenauthtest`, in the style of `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, ...)` mints an ID token. `WithLifetime` changes the lifetime (a negative value gives an expired token) and `WithIssuedAt` sets the issue time. In `WithClaims` a `nil` value removes a claim. Without the package you can set `verify.Config{TestingMode: true}` yourself.

## TypeScript

The module is the subpath `@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, ...)` mints an ID token. The options are `claims` (a `null` value removes a claim), `lifetimeSeconds` and `issuedAt`. Without the module you can pass `testing: true` to `createVerifier`.

## Python

The module is `keenauth.testing`; it needs the `verify` extra, like the verifier.

```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, ...)` mints an ID token. The keyword arguments are `claims` (a `None` value removes a claim), `lifetime_seconds` and `issued_at`. Without the module you can pass `testing=True` to `VerifierConfig`.

## Default claims

| Claim | Access token | ID token |
|---|---|---|
| `iss` | `https://test.keenauth.invalid` | `https://test.keenauth.invalid` |
| `sub` | `usr_01m3vnbwg0e8mbe7h7xxp6rmkc` | `usr_01m3vnbwg0e8mbe7h7xxp6rmkc` |
| `aud` | the audience you pass | the client ID you pass |
| `client_id` | `app_01m3vnbwg0fqba5ntanfd63s8n` | — |
| `iat`, `exp` | now, now + 3600 seconds | now, now + 3600 seconds |
| `jti` | random | — |
| `auth_time` | — | same as `iat` |

The access token header has `typ: at+jwt` (RFC 9068). Add the claims your API reads, such as `scope`, `org_id`, `permissions`, `acr`, `amr` or `auth_time`. For ID tokens, add `nonce` when your code checks one.

## Sample events

The kit includes sample payloads of common events: `user.created`, `user.updated`, `user.deleted`, `session.created`, `session.revoked`, `organization.created` and `organization_membership.created`. They use the same envelope and schemas as the events KeenAuth sends, in the default thin mode: identifiers, state and changed field names, no personal data. Use them to test your event handlers.

- Go: `keenauthtest.Event("user.created")` returns the JSON; `keenauthtest.EventTypes()` lists the types.
- TypeScript: `sampleEvent("user.created")` and `sampleEventTypes()`.
- Python: `testing.sample_event("user.created")` and `testing.event_types()`.

## Testing webhooks

The kit signs a payload exactly like a KeenAuth webhook delivery (Standard Webhooks v1): it returns the raw body and the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Your handler verifies it with the SDK's webhook verifier, the same code that runs in production. Only the secret differs.

- **A public test secret.** By default the kit signs with a fixed `idp_whsec_` test secret whose key is derived from the published label `KeenAuth offline test kit webhook secret (public, TEST ONLY)`. Anyone can compute it, so never configure it on a real endpoint. Configure your verifier with it in tests, or pass your own secret to the helper.
- **Same as a real delivery.** `webhook-id` is the event's `id` (or a random `msg_` ID when the payload has none) and `webhook-timestamp` is now, unless you set them. The body is sent exactly as you pass it.
- **Replay and tampering.** Sign with an old timestamp to test that your handler refuses deliveries outside the 5-minute window, and change the body after signing to test that it refuses a bad signature.

### 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)` takes a string, a `Uint8Array` or an object (serialized with `JSON.stringify`). The options are `secret`, `id` and `timestamp` (Unix seconds or a `Date`). To test an HTTP handler, send `delivery.body` as the raw request body with `delivery.headers` and `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)` takes bytes, a string or a dictionary (serialized as compact JSON) and returns `delivery.body` (bytes) and `delivery.headers` (a dictionary). With a test client such as Flask's or FastAPI's, post `delivery.body` as the raw body with `delivery.headers`.

### 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, ...)` returns `delivery.Body` and `delivery.Header`; `delivery.Request(url)` builds the POST request with `Content-Type: application/json`. The options are `WithWebhookSecret`, `WithWebhookID` and `WithWebhookTimestamp`.

## What the kit does not do

- It does not emulate KeenAuth. Sign-in flows, token issuance rules, rate limits and every other server behaviour are only real in your hosted `development` environment; run your integration tests there before you release.
- There is no test clock. To control time, pass your own clock to the verifier (`Now`, `now`) and set the issue time of the token.
- It does not create DPoP proofs. You can add a `cnf.jkt` claim, but then you must sign the proof yourself.
- It does not deliver webhooks. Real deliveries, retries and event selection are only real in your hosted `development` environment.
