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
- No consent screen, no sign-in page. Every valid authorization request is approved at once for a fixed, fictitious test user and redirected back with
code,stateandiss. The user'ssubisusr_01m3vnbwg0e8mbe7h7xxp6rmkc, the email addresstest.user@example.test(verified) and the nameTest User. - No registered clients. Any
client_idis accepted. Client secrets are not checked.redirect_urimust be an absolutehttpsURI, or anhttpURI on a loopback host (127.0.0.1,[::1],localhost), without a fragment; an invalidredirect_urior a missingclient_idgets an error page instead of a redirect. - PKCE is required.
code_challengewithcode_challenge_method=S256must be sent;plainis refused. The token request must send the matchingcode_verifier. - Codes are single use and expire after 60 seconds. Presenting a code a second time fails with
invalid_grantand revokes every token issued from it. - Tokens. The access token is an RFC 9068 JWT (
typat+jwt) valid for one hour, withsub,client_id,scopeand a randomjti. Its audience is theresourceparameter of the authorization request, orhttps://test.keenauth.invalid/oauth2/userinfowhen none was sent. With theopenidscope you also get an ID token (withnonceandauth_time;emailandnameclaims with theemailandprofilescopes). Withoffline_accessyou get a refresh token. - Refresh tokens rotate. Each refresh returns a new refresh token; presenting an old one fails and revokes the whole chain. A refresh may narrow the scope but not widen it.
- UserInfo accepts only access tokens this process issued, that have not expired or been revoked, and that carry the
openidscope.
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.
- Metadata.
GET /saml-idp/<application_id>/metadatareturns anEntityDescriptorwith the entity IDhttps://test.keenauth.invalid/saml-idp/<application_id>, the signing certificate, thepersistentandemailAddressNameID formats and the SSO endpoint for both bindings. Signed AuthnRequests are not required (WantAuthnRequestsSigned="false"); a signature on the request is ignored. - SSO. Send the AuthnRequest with the HTTP-Redirect binding (
GET, DEFLATE + base64 inSAMLRequest) or the HTTP-POST binding (POST, base64). There is no sign-in page: a valid request is approved at once for the test user and you get an HTML page that postsSAMLResponse(and yourRelayState, at most 80 bytes) to the service provider's ACS URL by itself. - The AuthnRequest must carry
ID,Version="2.0",IssueInstant, asaml:Issuer(your service provider's entity ID) andAssertionConsumerServiceURL, because the mock server has no registered service providers. The ACS URL must be an absolutehttpsURL, or anhttpURL on a loopback host, without a fragment.ProtocolBinding, if present, must be HTTP-POST;Destination, if present, must be the SSO URL. Any other request gets400and is not sent anywhere. DTDs are refused. - The response is signed twice with RSA-SHA256 and exclusive canonicalization: the assertion, then the whole response.
InResponseTois your request'sID,Destinationand the subject confirmationRecipientare the ACS URL, theAudienceis your request'sIssuer, and the assertion is valid from the moment it is issued for 5 minutes. The NameID ispersistent(a stable value per application) unless the request asks foremailAddress. The attributes areemail,given_name,family_nameandname.
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.
- Authentication. Every request needs
Authorization: Bearer keenauth-mock-scim-token. Anything else gets401with aWWW-Authenticate: Bearerchallenge. The value is fixed and public; it is not a KeenAuth SCIM token. - Users and groups.
POST,GET,PUT,PATCHandDELETEon/Usersand/Groups(RFC 7644).userNameand the groupdisplayNameare required and unique in the directory, ignoring case; a duplicate gets409withscimTypeuniqueness. Group members must be users of the same directory. Deleting a user removes it from its groups.passwordis accepted and discarded. - Filters and pages.
filtersupportseq,ne,co,sw,ewandprjoined withand(for exampleuserName eq "ada@example.test"), plusstartIndexandcount(at most 200).attributesandexcludedAttributesselect top-level attributes.POST /Users/.searchand/Groups/.searchtake the same parameters in a body. - PATCH supports
add,removeandreplacewith paths such asactive,name.givenName,emails[type eq "work"].value,members[value eq "<id>"]and enterprise extension attributes; the operations of one request are applied all or nothing. - Versions. Every resource carries
meta.versionand anETagheader (W/"1",W/"2", …).If-MatchonPUT,PATCHandDELETEreturns412when the version has changed;If-None-MatchonGETreturns304. - Discovery.
ServiceProviderConfig,ResourceTypesandSchemasdescribe exactly this behaviour./Bulkand/Mereturn501, as they do in KeenAuth.
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.
- Authentication. Every request needs
Authorization: Bearer keenauth-mock-management-token. Anything else gets401with aWWW-Authenticate: Bearerchallenge, also on unknown paths. The value is fixed and public; it is not a KeenAuth management key. - Responses. Each operation answers with the success status the document declares (
200,201,202,204; the two download endpoints answer302with aLocationthat does not resolve). The body is theexampleof the success response in the document or, as there are none yet, the smallest valid example built from the response schema: required fields only, nullable fieldsnull, enumerations with their first value, strings that match theirpattern. - Simple state. A collection that you can create records in (
POST /v1/users,/v1/organizations,/v1/applications,/v1/organizations/<org_id>/roles, …) keeps them: the record you created is returned byGETon its own path and by the list,PATCHandPUTchange it,DELETEremoves it together with everything below it, and a path under a record that does not exist is404. Identifiers have the KeenAuth prefix form (usr_…,org_…,app_…) and match the pattern in the document; where the document gives none, the prefix is derived from the parameter name. Settings that exist once per environment (for example/v1/identity-settings) keep what you wrote withPATCHorPUT. Resources that cannot be created through the API (for example sessions and reports) always answer with the example. - Lists.
limit(1–100, default 20),afterorbeforewith a record identifier as the cursor, andorder(descby default, newest first) work; the response carrieslist_metadata. Filters such asstatusare not applied. - Errors are
application/problem+jsonas in the document, withcodeand arequest_idthat is also in theIdp-Request-Idheader. An unknown path is404 not_found, a method the path does not have is405 method_not_allowedwith anAllowheader, a body that is not valid JSON (or not an object) is400 malformed_request, aContent-Typeother than JSON is415on the operations that list it, a missing required field is422 validation_failedwitherrors[], a body larger than 1 MiB is413, and a badlimitor cursor is400.
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.