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.invalidtop-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
.invalidas 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 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.
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.
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.
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")andsampleEventTypes(). - Python:
testing.sample_event("user.created")andtesting.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 labelKeenAuth 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-idis the event'sid(or a randommsg_ID when the payload has none) andwebhook-timestampis 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
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
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
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
developmentenvironment; 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.jktclaim, 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
developmentenvironment.