Mock OAuth2/OIDC provider

« Back to documentation home

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

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:

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
nameThe 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
issuerThe 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
audienceThe aud claim of every token.No aud claim
claimsA 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
tokenLifetimeSecondsA 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
publicBaseUrlThe 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, clientSecretSet 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:
    {
      "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), 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:

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:

{
  "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:

{"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:

{"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_credentials400 unsupported_grant_type
Credentials sent both by HTTP Basic and in the form400 invalid_request
With a client id and secret configured: a wrong or missing credential401 invalid_client, with a WWW-Authenticate: Basic realm="oauth2" header
Without them: any credentials, or none200 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:

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.

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 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
endpointdiscovery or token: which endpoint the mapping serves. Required.
basePathThe path the endpoint URLs are built under, /oauth2/orders for a mock named orders.
issuer, publicBaseUrl, audience, tokenLifetimeSeconds, claims, clientId, clientSecretAs 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.
signingKeyToken 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.

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