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

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.

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.

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