Local mock server

keenauth mock serve runs a small OpenID Connect, SAML 2.0 and SCIM 2.0 server, and an in-memory version of the /v1 management API, on your machine, for CI jobs without network access and for local development. It needs no KeenAuth environment, no API key and no internet. All data lives in memory and is deleted when the process exits.

The mock server is not KeenAuth. It implements only the flows below, approves every request automatically and checks much less than the real service. Your hosted development environment is the only reference for real behaviour: run your end-to-end tests against it before you release.

Start the server

keenauth mock serve

By default the server listens on 127.0.0.1:4010, a loopback address that other machines cannot reach. --port changes the port (0 picks a free one). --host changes the address; any address other than a loopback address exposes the mock server to your network, and the CLI prints a warning. Stop the server with Ctrl+C.

The command reads no credentials and sends nothing to KeenAuth.

Endpoints

Path Purpose
/.well-known/openid-configuration OIDC discovery document (also served at /.well-known/oauth-authorization-server)
/.well-known/jwks.json Public test key set (key ID keenauth-test-1)
/oauth2/authorize Authorization endpoint: authorization code flow with PKCE (S256)
/oauth2/token Token endpoint: authorization_code and refresh_token grants
/oauth2/userinfo UserInfo endpoint (Bearer access token with the openid scope)
/saml-idp/<application_id>/metadata SAML 2.0 identity provider metadata
/saml-idp/<application_id>/sso SAML single sign-on endpoint (HTTP-Redirect and HTTP-POST bindings)
/scim/v2/<directory_id>/… SCIM 2.0 endpoints: Users, Groups, ServiceProviderConfig, ResourceTypes, Schemas
/v1/… In-memory management API: every operation of the management API's OpenAPI document

The endpoint URLs in the discovery document and in the SAML metadata point to the local address the server listens on. <application_id> and <directory_id> are any names you choose (1–64 characters of A-Z, a-z, 0-9, -, _); nothing has to be registered first.

How it behaves

Tokens and your verifier

Tokens are signed with the public test key of the offline test kit and carry the issuer https://test.keenauth.invalid, not the local address. The .invalid top-level domain is reserved, so a production verifier can never accept these tokens: the SDK verifiers accept them only in testing mode, which you turn on explicitly in test code.

// Go: accept tokens from the mock server in a test.
v, err := keenauthtest.NewVerifier(verify.Config{Audience: "https://test.keenauth.invalid/oauth2/userinfo"})
if err != nil {
	t.Fatal(err)
}
claims, err := v.VerifyAccessToken(ctx, accessToken, nil)

Use the same testing mode in TypeScript (createTestVerifier) and Python (keenauth.testing.verifier). Generic OIDC client libraries that require the discovery document's issuer to equal the URL they fetched it from will refuse the mock server's discovery document; configure them with the endpoint URLs directly, or test them against your development environment.

Example: the full flow with curl

The example needs curl, openssl and jq. Start the server in another terminal first.

BASE=http://127.0.0.1:4010
REDIRECT=http://127.0.0.1:8080/callback
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

# 1. Authorize: the server answers with a redirect that carries the code.
LOCATION=$(curl -s -o /dev/null -w '%{redirect_url}' --get "$BASE/oauth2/authorize" \
  --data-urlencode response_type=code --data-urlencode client_id=app_local \
  --data-urlencode redirect_uri="$REDIRECT" --data-urlencode 'scope=openid email offline_access' \
  --data-urlencode state=xyz --data-urlencode code_challenge="$CHALLENGE" \
  --data-urlencode code_challenge_method=S256)
CODE=$(printf '%s' "$LOCATION" | sed -E 's/.*[?&]code=([^&]+).*/\1/')

# 2. Exchange the code for tokens.
TOKENS=$(curl -s "$BASE/oauth2/token" -d grant_type=authorization_code -d client_id=app_local \
  --data-urlencode code="$CODE" --data-urlencode redirect_uri="$REDIRECT" -d code_verifier="$VERIFIER")

# 3. Call UserInfo with the access token.
curl -s "$BASE/oauth2/userinfo" -H "Authorization: Bearer $(printf '%s' "$TOKENS" | jq -r .access_token)"

# 4. Refresh.
curl -s "$BASE/oauth2/token" -d grant_type=refresh_token -d client_id=app_local \
  --data-urlencode refresh_token="$(printf '%s' "$TOKENS" | jq -r .refresh_token)"

Step 3 prints the test user:

{"email":"test.user@example.test","email_verified":true,"sub":"usr_01m3vnbwg0e8mbe7h7xxp6rmkc"}

SAML identity provider

Use the mock server as the identity provider of a SAML service provider you are building: point the service provider at the metadata URL, sign in, and the mock server answers with a signed response for the test user. The paths follow the hosted SAML identity provider that KeenAuth plans for a later release.

The signing key is public. It is derived from a published label, so anyone can sign responses that this certificate verifies. Its SHA-256 fingerprint is fcc2fc37b9ddb800721283156f9eb26ff1b80f099d996a65650f8cb469a9be81 and its subject says TEST ONLY. Register it only in development and test configurations of your service provider, never in production. KeenAuth enforces this for its own SAML connections: in staging and production environments it refuses this key and any entity ID under .invalid, when you import metadata or add a certificate and again at sign-in; development environments accept them.

Example: a SAML sign-in with curl

The example needs curl and openssl. Start the server in another terminal first.

BASE=http://127.0.0.1:4010
APP=app_local
ACS=http://127.0.0.1:8080/saml/acs

# 1. IdP metadata: entity ID, signing certificate and SSO URL for your service provider.
curl -s "$BASE/saml-idp/$APP/metadata" -o idp-metadata.xml

# 2. Send an AuthnRequest with the HTTP-POST binding (base64, no compression).
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
REQUEST=$(printf '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req1" Version="2.0" IssueInstant="%s" AssertionConsumerServiceURL="%s"><saml:Issuer>https://sp.example.com</saml:Issuer></samlp:AuthnRequest>' "$NOW" "$ACS" | openssl base64 -A)
curl -s "$BASE/saml-idp/$APP/sso" --data-urlencode SAMLRequest="$REQUEST" --data-urlencode RelayState=xyz -o form.html

# 3. The page posts SAMLResponse to the ACS URL by itself; decode it to look at the signed response.
sed -n 's/.*name="SAMLResponse" value="\([^"]*\)".*/\1/p' form.html | openssl base64 -d -A > response.xml
grep -o 'InResponseTo="[^"]*"' response.xml | head -1

The last command prints InResponseTo="_req1". Your service provider validates response.xml against the certificate in idp-metadata.xml.

SCIM 2.0 endpoints

Use the SCIM endpoints to test a SCIM client, such as a provisioning connector or a script that creates users and groups, without a network. Each <directory_id> is a separate, empty directory that lives as long as the process.

Example: provision a user with curl

The example needs curl and jq. Start the server in another terminal first.

SCIM=http://127.0.0.1:4010/scim/v2/dir_local
AUTH='Authorization: Bearer keenauth-mock-scim-token'
CT='Content-Type: application/scim+json'

# 1. Create a user; the response carries the id, meta.version and an ETag header.
USER_ID=$(curl -s "$SCIM/Users" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "userName": "ada@example.test",
  "name": {"givenName": "Ada", "familyName": "Lovelace"},
  "emails": [{"value": "ada@example.test", "type": "work", "primary": true}]
}' | jq -r .id)

# 2. Find it with a filter.
curl -s -G "$SCIM/Users" -H "$AUTH" --data-urlencode 'filter=userName eq "ada@example.test"' | jq .totalResults

# 3. Deactivate it with PATCH, guarded by the version you read.
curl -s -X PATCH "$SCIM/Users/$USER_ID" -H "$AUTH" -H "$CT" -H 'If-Match: W/"1"' -d '{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [{"op": "replace", "path": "active", "value": false}]
}' | jq -c '{active, version: .meta.version}'

# 4. Create a group with the user as a member, then delete the user.
curl -s "$SCIM/Groups" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "displayName": "Engineering",
  "members": [{"value": "'"$USER_ID"'"}]
}' | jq -c '.members[0] | {display, type}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$SCIM/Users/$USER_ID" -H "$AUTH"

The commands print 1, {"active":false,"version":"W/\"2\""}, {"display":"ada@example.test","type":"User"} and 204.

Management API (/v1)

Use the management API endpoints to test code that calls the KeenAuth management API (an SDK, the CLI, a deployment script) in CI without a network. The paths are the hosted ones: use the base URL http://127.0.0.1:4010/v1 instead of the hosted one and nothing else changes. The endpoints are generated from the management API's OpenAPI document that is built into the CLI, so every operation of that version of the API answers.

What is different from the development environment. The mock server validates only that the body is a JSON object with the required fields; field formats, lengths, enumerations and uniqueness are not checked, and a field with the wrong type is ignored. It ignores If-Match, Idempotency-Key and the other headers and returns no ETag. It does not check permissions, plan limits, quotas, rate limits or the region, does not mask personal data, sends no e-mail, runs no jobs (an accepted job stays as it was created) and has one environment. Records exist only for the life of the process, at most 10,000 of them. Use your development environment to find out how the real API behaves.

Example: create, read, change and delete with curl

The example needs curl and jq. Start the server in another terminal first.

API=http://127.0.0.1:4010/v1
AUTH='Authorization: Bearer keenauth-mock-management-token'
CT='Content-Type: application/json'

# 1. Create an organization.
ORG_ID=$(curl -s "$API/organizations" -H "$AUTH" -H "$CT" -d '{"name": "Acme"}' | jq -r .id)

# 2. Read it back and list.
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{id: (.id | .[0:4]), name}'
curl -s "$API/organizations" -H "$AUTH" | jq -c '{count: (.data | length), list_metadata}'

# 3. Rename it with a merge patch.
curl -s -X PATCH "$API/organizations/$ORG_ID" -H "$AUTH" -H 'Content-Type: application/merge-patch+json' -d '{"name": "Acme Inc"}' | jq -r .name

# 4. Delete it; a second read is a 404 problem.
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$API/organizations/$ORG_ID" -H "$AUTH"
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{status, code}'

# 5. Without the token.
curl -s "$API/organizations" | jq -c '{status, code}'

The commands print {"id":"org_","name":"Acme"}, {"count":1,"list_metadata":{"after":null,"before":null}}, Acme Inc, 204, {"status":404,"code":"not_found"} and {"status":401,"code":"unauthenticated"}.

Not supported

There is no test clock, no DPoP, PAR, request objects, device flow, client credentials, token exchange, logout or revocation, and no hosted pages. The SAML identity provider has no single logout, no encrypted assertions, no artifact binding and no IdP-initiated sign-in. The SCIM endpoints have no bulk operations, sorting, /Me or nested groups, and the filter has no or, not, parentheses or gt/ge/lt/le. Use your development environment to test them.