# Mock OAuth2/OIDC provider

## An identity provider for your system under test

A system that authenticates against an OAuth2 or OpenID Connect provider needs one in every test environment: somewhere to discover the provider, fetch its signing keys and obtain tokens. Traffic Parrot scaffolds such a provider into a scenario in one request. The mock serves:

- an **OpenID Connect discovery document** at `/.well-known/openid-configuration`, whose endpoint URLs resolve against the address the system under test reached the mock on;
- a **JWKS** publishing the public half of the mock's own signing key;
- a **token endpoint** for the `client_credentials` grant, issuing RS256-signed JWTs that verify against that JWKS, with an optional client id and secret check.

The three endpoints are three ordinary [HTTP mappings](https://trafficparrot.com/documentation/5.65.x/http.html) in the selected scenario, listed, edited, exported and deleted like any other. Each mock signs with its own RSA key, kept in the token mapping, so the key id it publishes and every token it issued stay valid across a restart of Traffic Parrot. Any stub in the scenario can then [require a valid token from the mock](https://trafficparrot.com/documentation/5.65.x/oauth2-mock.html#guard), so the mocked API refuses a request the real one would refuse.

A real OIDC client, such as Spring Security configured with an `issuer-uri`, completes discovery and token acquisition against the mock without special handling. This is a facility for mocking the identity provider the system under test talks to; it is unrelated to the [login to Traffic Parrot's own web user interface](https://trafficparrot.com/documentation/5.65.x/properties.html#gui-properties).

## Scaffolding a mock

Send `POST /api/http/oauth2-mocks` to the web user interface port (`8080` by default) with a JSON body. Every field is optional, and a request with no body scaffolds a mock named `default` with every default below:

```bash
curl -s -X POST localhost:8080/api/http/oauth2-mocks -H 'Content-Type: application/json' \
  -d '{"name":"orders","audience":"orders-api","clientId":"orders-client","clientSecret":"s3cret"}'
```

| Field | What it sets | Default |
| --- | --- | --- |
| `name` | The mock's name: 1 to 63 lowercase letters, digits and hyphens, starting with a letter or digit. It names the mock's path, `/oauth2/<name>`, its three mappings, and the [guard](https://trafficparrot.com/documentation/5.65.x/oauth2-mock.html#guard) on a stub. | `default` |
| `issuer` | The `issuer` of the discovery document and the `iss` claim of every token: an absolute `http` or `https` URL with no query or fragment. Setting it also moves the endpoints under the issuer's path; see [Issuer and public base URL](https://trafficparrot.com/documentation/5.65.x/oauth2-mock.html#addresses). | Derived from each request |
| `audience` | The `aud` claim of every token. | No `aud` claim |
| `claims` | A JSON object of extra claims added to every token, `{"tenant": "acme", "roles": ["admin"]}` for example. It cannot set `iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti` or `scope`, which the token endpoint sets itself. | None |
| `tokenLifetimeSeconds` | A token's `exp` is its `iat` plus this. Zero or a negative number issues a token that has already expired, which is how a test exercises expiry without waiting. | `3600` |
| `publicBaseUrl` | The scheme, host, port and optional path prefix the mock advertises in the discovery document and signs into the default `iss`, for a mock reached through a proxy or on a published port; an absolute `http` or `https` URL with no query or fragment. | The scheme and authority each request arrived on |
| `clientId`, `clientSecret` | Set both, and the token endpoint issues only to a client presenting exactly these, by HTTP Basic or in the form. Set neither, and it issues to any caller. Neither may be blank. | Not checked |

The endpoint answers:

- `201` with the mock's name, the paths of its three endpoints on the HTTP virtual service port (`8081` by default), the key id (`kid`) it signs with, and the ids of the three mappings:

  ```json
  {
    "name": "orders",
    "discoveryPath": "/oauth2/orders/.well-known/openid-configuration",
    "jwksPath": "/oauth2/orders/jwks",
    "tokenPath": "/oauth2/orders/token",
    "keyId": "pQd6...o4c",
    "mappingIds": ["6a6ec0b4-...", "0f0d8f28-...", "8b1e9c7a-..."]
  }
  ```

- `400` with `{"error": "..."}` naming the field at fault when a value is not acceptable: a name outside the pattern, an issuer or public base URL that is not an absolute http(s) URL, a client id without a secret or the other way round, a blank one, or an extra claim that is one of the reserved names.

- `409` with `{"error": "...", "mappingIds": [...]}` when the selected scenario already has a mock of that name, or when mappings in it already answer one of the three paths with the same method or any method. A mock is never scaffolded over an existing one, because that would silently replace the key that tokens in use were signed with. Delete the mappings the message lists, or choose another name or issuer.

When the web user interface login is on ([`trafficparrot.gui.security.mode=LOGIN_PROPERTIES`](https://trafficparrot.com/documentation/5.65.x/properties.html#gui-properties)), the request needs a user holding `traffic-parrot-gui-edit-role`; a signed-in user without it gets `403` and nothing is written.

The three mappings are written to the selected scenario's `mappings` directory, named `OAuth2 mock <name> - discovery document`, `- JWKS` and `- token endpoint`, and tagged with the metadata `"oauth2Mock": "<name>"`, which is how the guard finds the mock's keys. On the HTTP mappings page they are easiest to find with the **Mapping name** column turned on under **Column visibility**, which is off by default:

![HTTP mappings table filtered by the search text OAuth2 mock default, listing three mappings with the Mapping name column shown: the token endpoint as POST /oauth2/default/token, the discovery document as GET /oauth2/default/.well-known/openid-configuration and the JWKS as GET /oauth2/default/jwks with a response body starting with a keys array](https://trafficparrot.com/documentation/5.65.x/img/http/oauth2-mock-mappings.png)

## What the mock serves

### The discovery document

`GET /oauth2/<name>/.well-known/openid-configuration` answers the OpenID Connect discovery document. Its `issuer`, `token_endpoint` and `jwks_uri` are built from the address the request arrived on, or from `publicBaseUrl` and `issuer` when set:

```json
{
  "issuer": "http://localhost:8081/oauth2/orders",
  "token_endpoint": "http://localhost:8081/oauth2/orders/token",
  "jwks_uri": "http://localhost:8081/oauth2/orders/jwks",
  "grant_types_supported": ["client_credentials"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "response_types_supported": ["token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}
```

There is no `authorization_endpoint`, because the mock serves none; a client that only needs discovery, keys and the `client_credentials` grant does not ask for one.

### The JWKS

`GET /oauth2/<name>/jwks` answers a JSON Web Key Set holding the public half of the mock's RSA key, with the `kid` every token's header carries:

```json
{"keys": [{"kty": "RSA", "kid": "pQd6...o4c", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB"}]}
```

The body is fixed at scaffold time: the JWKS mapping is a plain static response and holds no private key material.

### The token endpoint

`POST /oauth2/<name>/token` takes a form-encoded body with `grant_type=client_credentials` and an optional `scope`, and answers as RFC 6749 describes:

```json
{"access_token": "eyJraWQiOiJwUWQ2...", "token_type": "Bearer", "expires_in": 3600}
```

The access token is a JWT signed with RS256 under the mock's `kid`. Its claims are `iss`, `aud` when an audience is configured, `sub` (the client id the request presented, when it presented one), `iat`, `exp`, a unique `jti`, `scope` when the request asked for one (also echoed in the response), and the extra claims configured at scaffold time. The response carries `Cache-Control: no-store`, and `expires_in` is `0` for a zero or negative lifetime.

A client authenticates by HTTP Basic (`client_secret_basic`) or with `client_id` and `client_secret` form fields (`client_secret_post`). Basic credentials are read form-decoded first, as the standard has them encoded, and then as sent, so `curl -u` with an unencoded secret works too. Errors are JSON with `error` and `error_description`:

| Request | Answer |
| --- | --- |
| No `grant_type`, or a body that is not a form (a JSON body, for example) | `400 invalid_request` |
| A `grant_type` other than `client_credentials` | `400 unsupported_grant_type` |
| Credentials sent both by HTTP Basic and in the form | `400 invalid_request` |
| With a client id and secret configured: a wrong or missing credential | `401 invalid_client`, with a `WWW-Authenticate: Basic realm="oauth2"` header |
| Without them: any credentials, or none | `200` with a token; `sub` is the client id presented, if any |

## Issuer and public base URL

By default the mock works out its own address per request, from the scheme and the `Host` header the request arrived with, and mounts its endpoints under `/oauth2/<name>`. So a mock reached as `http://localhost:8081` advertises and signs `http://localhost:8081/oauth2/orders` as its issuer, and the same mock reached as `http://mocks:8081` from a container advertises `http://mocks:8081/oauth2/orders`. A client that validates the token's `iss` against the issuer it discovered is satisfied either way, as long as it obtained the token through the same address it discovered the mock on. `X-Forwarded-*` headers are not read.

Two settings pin the addresses when that default is not what the system under test expects:

- `publicBaseUrl` replaces the scheme and authority taken from the request in every advertised URL and in the default issuer. Set it when the mock sits behind a reverse proxy or is reached on a published port that differs from the one Traffic Parrot listens on: a Docker Compose service that reaches Traffic Parrot as `http://tp:8081` while the test driving it uses `localhost` should be given the address the system under test uses. A base URL with a path prefix joins correctly.
- `issuer` fixes the `issuer` and the `iss` claim to one URL, and mounts the three endpoints under that URL's path rather than under `/oauth2/<name>`: an issuer of `https://idp.example.test/realms/orders` puts the discovery document at `/realms/orders/.well-known/openid-configuration`, so a client configured with that issuer and routed to Traffic Parrot discovers the mock where it looks for it, and finds the issuer it expects in the answer. An issuer with no path mounts the mock at the root of the virtual service, `/.well-known/openid-configuration`, `/jwks` and `/token`, which the scaffold refuses with `409` if your own mappings already answer there.

## Walkthrough with curl

With Traffic Parrot running on the default ports, scaffold a mock that checks client credentials and issues tokens for an audience:

```bash
curl -s -X POST localhost:8080/api/http/oauth2-mocks -H 'Content-Type: application/json' \
  -d '{"name":"orders","audience":"orders-api","clientId":"orders-client","clientSecret":"s3cret"}'

curl -s localhost:8081/oauth2/orders/.well-known/openid-configuration
curl -s localhost:8081/oauth2/orders/jwks

curl -s -u orders-client:s3cret -d grant_type=client_credentials localhost:8081/oauth2/orders/token
curl -s -u orders-client:wrong -d grant_type=client_credentials localhost:8081/oauth2/orders/token
```

The fourth call answers `{"access_token": "...", "token_type": "Bearer", "expires_in": 3600}`; the last answers `401` with `{"error": "invalid_client", "error_description": "The client credentials are wrong or missing."}`. Paste the access token into a JWT decoder: `iss` is `http://localhost:8081/oauth2/orders`, `aud` is `orders-api`, `sub` is `orders-client`, and the header's `kid` is the one the JWKS publishes. Restart Traffic Parrot and fetch the JWKS again: the `kid` is unchanged, and the token still verifies.

Point the system under test at the mock as it would be pointed at the real provider. A Spring Boot resource server validates tokens against it with

```properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8081/oauth2/orders
```

and a Spring Boot client obtains them with

```properties
spring.security.oauth2.client.provider.orders.issuer-uri=http://localhost:8081/oauth2/orders
spring.security.oauth2.client.registration.orders.client-id=orders-client
spring.security.oauth2.client.registration.orders.client-secret=s3cret
spring.security.oauth2.client.registration.orders.authorization-grant-type=client_credentials
```

Such a client fetches `<issuer>/.well-known/openid-configuration` and checks that the document names the issuer it was given, so when the system under test reaches Traffic Parrot by another host name than the one in its configuration, set `issuer` or `publicBaseUrl` at scaffold time to the address the system under test uses.

## The signing key and the mapping files

Each scaffold generates a 2048-bit RSA key. Its `kid` is the key's RFC 7638 thumbprint, so it follows from the key itself. The private key is kept, as a JSON Web Key, in the token mapping's `transformerParameters`, and the public half is the JWKS mapping's body. Because the key lives in the mapping files, it is saved, exported, imported and reloaded with them, which is what keeps the published `kid` and every issued token valid across a restart. It is a mock's key: anyone who can read the scenario's mapping files, or fetch the token mapping through the mappings API, WireMock's admin API on the virtual service port included, can mint tokens the mock accepts, and with no client id and secret set the token endpoint issues one to anyone who asks. Never configure a production service to trust this mock's issuer or JWKS.

To rotate the key, delete the three mappings and scaffold the mock again; the scaffold never overwrites an existing mock. Editing the token mapping's key by hand without changing the JWKS mapping to match leaves tokens that no longer verify, as editing any two related mappings inconsistently would.

The discovery document and the token endpoint are rendered by the `oauth2Provider` response transformer from the parameters stored in the mapping, so you can edit them in the mapping file. Do not save them in the [mapping editor](https://trafficparrot.com/documentation/5.65.x/http.html#add-edit-http-mappings), which drops that transformer (see [below](https://trafficparrot.com/documentation/5.65.x/oauth2-mock.html#guard)). The token mapping looks like this, with the key shortened:

```json
{
  "id": "8b1e9c7a-...",
  "name": "OAuth2 mock orders - token endpoint",
  "request": {
    "urlPath": "/oauth2/orders/token",
    "method": "POST"
  },
  "response": {
    "status": 200,
    "transformers": ["oauth2Provider"],
    "transformerParameters": {
      "oauth2Provider": {
        "endpoint": "token",
        "basePath": "/oauth2/orders",
        "audience": "orders-api",
        "clientId": "orders-client",
        "clientSecret": "s3cret",
        "signingKey": {"kty": "RSA", "kid": "pQd6...o4c", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB", "d": "...", "p": "...", "q": "...", "dp": "...", "dq": "...", "qi": "..."}
      }
    }
  },
  "uuid": "8b1e9c7a-...",
  "metadata": {"oauth2Mock": "orders"},
  "insertionIndex": 0
}
```

| Parameter | Meaning |
| --- | --- |
| `endpoint` | `discovery` or `token`: which endpoint the mapping serves. Required. |
| `basePath` | The path the endpoint URLs are built under, `/oauth2/orders` for a mock named `orders`. |
| `issuer`, `publicBaseUrl`, `audience`, `tokenLifetimeSeconds`, `claims`, `clientId`, `clientSecret` | As at scaffold time; absent when left at the default. A value must be of the right type, a string for the strings, a whole number for the lifetime and an object for the claims. `clientId` and `clientSecret` go together. |
| `signingKey` | Token mapping only: the RSA private key in JSON Web Key form, with its private members `d`, `p`, `q`, `dp`, `dq` and `qi`. |

A mapping whose parameters cannot be used, because a value has the wrong type, a client id has no secret or the signing key is malformed, answers `500` with a JSON `server_error` whose description names the parameter at fault, quoting a wrong `endpoint` or `tokenLifetimeSeconds` value but never the client secret or the key, rather than a `200` with no token in it. The JWKS mapping has no transformer and no parameters: its body is the key set.

**The key and the client secret reach the log.** With the shipped [`trafficparrot.virtualservice.httpRequestLoggingDisabled=false`](https://trafficparrot.com/documentation/5.65.x/properties.html#virtualservice-properties), the per-request entry in `trafficparrot.log` records the matched mapping's response definition, so after a token request the token mapping's signing key and client secret are in the [log](https://trafficparrot.com/documentation/5.65.x/logging.html), as the raw `Authorization` header of every request already is. Where the log is shared more widely than the mapping files, set that property to `true`.

## Requiring a token from the mock on a stub

A stub guarded with the `matchesValidBearerToken` request matcher matches only a request whose `Authorization` header carries a valid, unexpired token issued by the named mock, so a system under test that forgets the token, sends an expired one or presents a token from another provider is refused by the mock as it would be by the real API. The matcher is a key of the `requestMatchers` custom matcher, beside the [request matching script](https://trafficparrot.com/documentation/5.65.x/request_matching.html#verify-signed-webhook):

```json
{
  "priority": 1,
  "request": {
    "method": "GET",
    "urlPath": "/orders",
    "customMatcher": {
      "name": "requestMatchers",
      "parameters": {
        "matchesValidBearerToken": {"oauth2Mock": "orders"}
      }
    }
  },
  "response": {
    "status": 200,
    "headers": {"Content-Type": "application/json"},
    "body": "[{\"id\": 1, \"status\": \"SHIPPED\"}]"
  }
}
```

`oauth2Mock` names the mock, as given to the scaffold; left out, the mock named `default` is meant. In the HTTP mapping editor the same guard is the **Require OAuth2 bearer token from mock** field under the [request matching script](https://trafficparrot.com/documentation/5.65.x/http.html#add-edit-request-matcher-script): type the mock's name, or leave it blank for no guard. Saving a mapping in the editor keeps the guard, and a guard beside a matching script keeps both, in which case the request must satisfy both.

![HTTP mapping editor with Request URL equal to /orders, Request method GET, Request priority 1, Request matching script none, the Require OAuth2 bearer token from mock field holding orders, and a Successful response with status code 200 and the body guarded by orders](https://trafficparrot.com/documentation/5.65.x/img/http/oauth2-guard-field.png)

A request passes the guard when all of these hold:

- its `Authorization` header is `Bearer <token>` (the scheme in any case); a token in a form field or the query string is not read;
- the token is a JWT signed with RS256 by a key the mock publishes, which the matcher reads from the mock's JWKS mapping in the same scenario on every request, so a guarded stub accepts exactly the tokens a client validating against that JWKS would. A token under any other algorithm, or signed by another mock's key, or by another key under this mock's `kid`, fails;
- the token carries an `exp` that is still in the future, with no leeway.

The signature is what proves which mock issued the token, so `iss` and `aud` are not compared: the default issuer depends on the address the token was requested through, which the system under test may reach differently from the test.

A request the guard refuses does not match the stub, so it falls through to the next mapping on the same URL, or to the usual no-match response when there is none. To answer `401` as the real API would, add a second mapping on the same URL at a lower priority (a higher number), as the [signed webhook example](https://trafficparrot.com/documentation/5.65.x/request_matching.html#verify-signed-webhook) does:

```json
{
  "priority": 2,
  "request": {"method": "GET", "urlPath": "/orders"},
  "response": {
    "status": 401,
    "headers": {"WWW-Authenticate": "Bearer"}
  }
}
```

The mock and the stubs it guards must be in the same scenario, because the matcher reads the keys from the mappings of the server that received the request; a scenario on a dedicated port sees that scenario's own mappings. The guard applies to HTTP mappings only.

**Edit the mock's three mappings as JSON, not in the editor.** Saving the mock's JWKS mapping in the mapping editor drops its `oauth2Mock` metadata, after which the matcher finds no keys for the mock and every guard on it refuses every token. Saving its discovery or token mapping there drops the `oauth2Provider` response transformer, after which that endpoint answers an empty `200`. The editor is fine for the stubs the guard is on. To change one of the mock's mappings, edit its file under `mappings`, update it through `PUT /api/http/__admin/mappings/<id>`, or use the [`update_mapping` MCP tool](https://trafficparrot.com/documentation/5.65.x/mcp.html), each of which keeps the metadata and the transformer as long as the JSON you write or send still carries them. A guard that stops matching after an edit to the JWKS mapping is this, and the log says so (below).

**In the log.** A refused token is one `INFO` line, `Bearer token refused for OAuth2 mock 'orders': <reason>`, where the reason is `it has expired`, `it has no exp claim`, `its signature does not verify against the mock's JWKS` or, for anything else, `it is not a valid RS256 JWT from this mock` with the validation library's error codes; the token itself is never logged. A guard that names a mock no mapping publishes a JWKS for logs a `WARN` naming the mock, the mocks that do publish one, and the likely causes (the JWKS mapping deleted, disabled, edited so its body is no longer a key set, or saved in the editor). A guard whose `oauth2Mock` is not a string, or a `requestMatchers` key that names no matcher (a misspelt `matchesValidBearerToken`), fails the request with a `500` that names the key, rather than matching on the other matchers alone.

**Cost.** The guard runs only after the stub's URL, method, headers and body matchers have matched, so it costs nothing elsewhere. For each request that reaches it, the matcher walks the server's mappings and parses the named mock's JWKS. With the shipped [`trafficparrot.virtualservice.mapping.cache.milliseconds=0`](https://trafficparrot.com/documentation/5.65.x/properties.html#caching-properties) that walk re-reads the mapping directory from disk, so a load test aimed at a guarded stub pays a directory read per request; a non-zero cache makes it an in-memory walk.

## What the mock does not do

- The `authorization_code` grant and an interactive login page: the mock has no authorization endpoint, and its discovery document advertises none.
- Refresh tokens, and token introspection or revocation endpoints.
- PKCE and dynamic client registration.
- Scope checks: a requested `scope` is echoed into the token and the response as sent.
- Transports other than HTTP: the guard reads the HTTP `Authorization` header, not gRPC metadata.

The three mappings are ordinary mappings, so what they do not do out of the box can often be added as another mapping beside them, with a [dynamic response](https://trafficparrot.com/documentation/5.65.x/dynamic.html) where a fixed one will not do.
