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:
The three endpoints are three ordinary HTTP mappings 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.
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.
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:
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>, and its three mappings. | 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. | 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:
{
"name": "orders",
"discoveryPath": "/oauth2/orders/.well-known/openid-configuration",
"jwksPath": "/oauth2/orders/jwks",
"tokenPath": "/oauth2/orders/token",
"keyId": "pQd6...o4c",
"mappingIds": ["6a6ec0b4-...", "0f0d8f28-...", "8b1e9c7a-..."]
}When the web user interface login is on (trafficparrot.gui.security.mode=LOGIN_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 a mock of the same name is recognised. 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:
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:
{
"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.
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:
{"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.
POST /oauth2/<name>/token takes a form-encoded body with grant_type=client_credentials and an optional scope, and answers as RFC 6749 describes:
{"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 |
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.With Traffic Parrot running on the default ports, scaffold a mock that checks client credentials and issues tokens for an audience:
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
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8081/oauth2/orders
and a Spring Boot client obtains them with
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.
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 or in the mapping editor, which shows transformerParameters as JSON. The token mapping looks like this, with the key shortened:
{
"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, 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, 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.
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 where a fixed one will not do.