
# 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

```sh
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`, `state` and `iss`. The user's `sub` is `usr_01m3vnbwg0e8mbe7h7xxp6rmkc`, the email address `test.user@example.test` (verified) and the name `Test User`.
- **No registered clients.** Any `client_id` is accepted. Client secrets are not checked. `redirect_uri` must be an absolute `https` URI, or an `http` URI on a loopback host (`127.0.0.1`, `[::1]`, `localhost`), without a fragment; an invalid `redirect_uri` or a missing `client_id` gets an error page instead of a redirect.
- **PKCE is required.** `code_challenge` with `code_challenge_method=S256` must be sent; `plain` is refused. The token request must send the matching `code_verifier`.
- **Codes are single use** and expire after 60 seconds. Presenting a code a second time fails with `invalid_grant` and revokes every token issued from it.
- **Tokens.** The access token is an RFC 9068 JWT (`typ` `at+jwt`) valid for one hour, with `sub`, `client_id`, `scope` and a random `jti`. Its audience is the `resource` parameter of the authorization request, or `https://test.keenauth.invalid/oauth2/userinfo` when none was sent. With the `openid` scope you also get an ID token (with `nonce` and `auth_time`; `email` and `name` claims with the `email` and `profile` scopes). With `offline_access` you 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 `openid` scope.

## Tokens and your verifier

Tokens are signed with the public test key of the [offline test kit](/docs/sdk/testing) 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
// 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.

```sh
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:

```json
{"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>/metadata` returns an `EntityDescriptor` with the entity ID `https://test.keenauth.invalid/saml-idp/<application_id>`, the signing certificate, the `persistent` and `emailAddress` NameID 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 in `SAMLRequest`) 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 posts `SAMLResponse` (and your `RelayState`, at most 80 bytes) to the service provider's ACS URL by itself.
- **The AuthnRequest must carry** `ID`, `Version="2.0"`, `IssueInstant`, a `saml:Issuer` (your service provider's entity ID) and `AssertionConsumerServiceURL`, because the mock server has no registered service providers. The ACS URL must be an absolute `https` URL, or an `http` URL 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 gets `400` and is not sent anywhere. DTDs are refused.
- **The response** is signed twice with RSA-SHA256 and exclusive canonicalization: the assertion, then the whole response. `InResponseTo` is your request's `ID`, `Destination` and the subject confirmation `Recipient` are the ACS URL, the `Audience` is your request's `Issuer`, and the assertion is valid from the moment it is issued for 5 minutes. The NameID is `persistent` (a stable value per application) unless the request asks for `emailAddress`. The attributes are `email`, `given_name`, `family_name` and `name`.

**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.

```sh
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 gets `401` with a `WWW-Authenticate: Bearer` challenge. The value is fixed and public; it is not a KeenAuth SCIM token.
- **Users and groups.** `POST`, `GET`, `PUT`, `PATCH` and `DELETE` on `/Users` and `/Groups` (RFC 7644). `userName` and the group `displayName` are required and unique in the directory, ignoring case; a duplicate gets `409` with `scimType` `uniqueness`. Group members must be users of the same directory. Deleting a user removes it from its groups. `password` is accepted and discarded.
- **Filters and pages.** `filter` supports `eq`, `ne`, `co`, `sw`, `ew` and `pr` joined with `and` (for example `userName eq "ada@example.test"`), plus `startIndex` and `count` (at most 200). `attributes` and `excludedAttributes` select top-level attributes. `POST /Users/.search` and `/Groups/.search` take the same parameters in a body.
- **PATCH** supports `add`, `remove` and `replace` with paths such as `active`, `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.version` and an `ETag` header (`W/"1"`, `W/"2"`, …). `If-Match` on `PUT`, `PATCH` and `DELETE` returns `412` when the version has changed; `If-None-Match` on `GET` returns `304`.
- **Discovery.** `ServiceProviderConfig`, `ResourceTypes` and `Schemas` describe exactly this behaviour. `/Bulk` and `/Me` return `501`, as they do in KeenAuth.

### Example: provision a user with curl

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

```sh
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 gets `401` with a `WWW-Authenticate: Bearer` challenge, 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 answer `302` with a `Location` that does not resolve). The body is the `example` of 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 fields `null`, enumerations with their first value, strings that match their `pattern`.
- **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 by `GET` on its own path and by the list, `PATCH` and `PUT` change it, `DELETE` removes it together with everything below it, and a path under a record that does not exist is `404`. 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 with `PATCH` or `PUT`. Resources that cannot be created through the API (for example sessions and reports) always answer with the example.
- **Lists.** `limit` (1–100, default 20), `after` or `before` with a record identifier as the cursor, and `order` (`desc` by default, newest first) work; the response carries `list_metadata`. Filters such as `status` are not applied.
- **Errors** are `application/problem+json` as in the document, with `code` and a `request_id` that is also in the `Idp-Request-Id` header. An unknown path is `404 not_found`, a method the path does not have is `405 method_not_allowed` with an `Allow` header, a body that is not valid JSON (or not an object) is `400 malformed_request`, a `Content-Type` other than JSON is `415` on the operations that list it, a missing required field is `422 validation_failed` with `errors[]`, a body larger than 1 MiB is `413`, and a bad `limit` or cursor is `400`.

**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.

```sh
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.
