Issuers and advertised identity¶
An OIDC provider has an identity: a URL that names it. Clients pin that URL —
they fetch discovery and JWKS from it, and they reject any token whose iss
does not match it. A mock that stands in for real providers therefore has to
answer two questions convincingly. Who is minting this token — which issuer's
key signed it, and which trust domain does it belong to? And where does that
issuer live — what URL should the token claim, so the client under test
actually believes it?
mock-oidc answers the first question with a namespace and the second by deriving the URL fresh on every request. The two mechanisms are independent, and understanding why they are separate is the key to understanding the whole identity model.
One server, many issuers¶
Everything the server does is namespaced under a single path segment: the
issuer. Discovery, JWKS, authorize, token, userinfo — all of it lives
under /{issuer}/.... With zero configuration the server answers as one issuer
named default, reachable at http://localhost:8080/default, but that name is
not special. It is simply the issuer you get when you do not ask for another.
Issuers are not registered. They materialize on first touch: the first time
any request hits /{some-id}/..., that issuer springs into existence with a
lazily generated signing key, and every subsequent request under the same
segment shares it. There is no create step, no config entry, no restart.
This is a deliberate inversion of how a real provider works, and it is chosen for what a test needs. A test suite wants to drive a sign-in without first provisioning a tenant; picking an issuer name and using it is the provisioning. The same property lets a single running container impersonate an arbitrary number of independent IdPs at once — one for each name a test cares to invent — which is exactly what you want when exercising multi-tenant isolation, federation across several providers, or an audience matrix. The alternative, an explicit registration API or a config block listing every issuer up front, would buy nothing here except setup: it trades away the zero-friction property that makes the mock worth using.
Each materialized issuer is its own trust domain. Its signing key carries
kid equal to the issuer id, so a verifier can route its trust off the issuer
name alone, and — more importantly — verification is isolated. A token minted
under one issuer is worthless to another: the second issuer's JWKS never
advertises the first one's key, so its userinfo rejects the foreign token and
its introspect reports it inactive. That isolation is not an add-on; it falls out of every
issuer having a distinct key and a distinct discovery document. (For the wider
consequences of "any string becomes a trusted, key-bearing issuer," see
The security model.)
The cost of materialize-on-touch is that there is no such thing as a typo. Ask
for /defalt/token and you have not hit an error — you have created and used a
brand-new issuer named defalt, with its own key and its own iss. That is a
reasonable price for a testing tool, but it is why the model belongs to a
process you run and throw away, never one that fronts real traffic.
The advertised-identity problem¶
The harder question is where an issuer lives. A real provider is reached at
one canonical URL and advertises exactly that. A mock is not so lucky: the
same running server is reached under several different names at the same
time. A developer curls it at http://localhost:8080. An app inside a
container reaches it through a Docker network alias or the host gateway. A
reverse proxy answers for it at https://idp.example.com on port 443 and
forwards inward. Each caller sees a different address — and OIDC gives the
server no room to fudge, because the client compares the issuer in discovery
and the iss in the token against the URL it actually fetched discovery from.
If those disagree, verification fails.
A fixed, configured base URL cannot solve this. Whatever single value you bake
in at startup is correct for exactly one of those callers and wrong for the
rest. The proxy topology wants https://idp.example.com; the localhost
developer wants http://localhost:8080; the container wants the host-gateway
name. No constant is right for all three simultaneously.
So mock-oidc does not use a constant. It derives every advertised URL — the
discovery issuer, every *_endpoint, jwks_uri, and the iss stamped into
tokens — per request, from the address the request appears to have arrived
at. It reads X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port
when a proxy set them, and falls back to the request's own Host header when it
did not. It keeps only the host root — scheme plus authority — discarding the
request path, and then appends the issuer segment. The advertised identity, in
other words, is a function of how you reached the server, computed anew every
time.
That single decision is why the awkward topologies "just work" without any matching configuration:
- Behind a proxy, the proxy forwards
X-Forwarded-*describing the external address it answers on. A discovery request that entered asX-Forwarded-Proto: https,X-Forwarded-Host: idp.example.comcomes back advertisinghttps://idp.example.com/default, and tokens minted on that request claim the sameiss— even though the mock's own listener is plain HTTP on:8080. - Across two containers, the app under test and the browser must agree on
one issuer URL, because the app fetches discovery and JWKS from it while the
browser is redirected to
authorizeon it. Give both sides the same reachable name —host.docker.internal:8080is the usual one on Docker Desktop — and because the mock derivesissfrom the incomingHost, it advertises exactly that name back to both. Remapping the published port needs no configuration either: the mock sees the new port inHostand advertises it.
The mirror image of this is that the mock only advertises what it is told. If a
proxy strips the forwarded headers, the mock falls back to the Host it sees
and will advertise the internal address — correct behavior for a wrong input.
The identity is honest about the address the request carried, which is the most
a per-request derivation can promise. What exactly ends up in a token, iss
included, is catalogued in Tokens and claims;
the practical recipes live in
Run behind a proxy or in Docker.
The single-segment constraint¶
An issuer id is one path segment. It cannot contain a /. This is a real
limitation with a real consequence, and it is documented here rather than
papered over, because papering over it would be worse.
Some real providers publish nested issuer URLs. Azure AD is the canonical
example: its issuer looks like https://login.microsoftonline.com/{tenant}/v2.0
— several path segments deep. mock-oidc cannot represent that shape. A request
to /tenant/v2.0/token does not create a nested issuer called tenant/v2.0;
it is read as the issuer tenant with v2.0/token as a path beneath it. There
is no configuration that changes this.
The reason is routing. Single-segment issuers map cleanly onto path-parameter
routing — /{issuer}/... — where the router extracts exactly one segment and
everything after it is a known, fixed endpoint. Allowing multi-segment issuers
would make the boundary between "issuer" and "endpoint" ambiguous: the router
could not tell where the issuer name ends and the OAuth path begins without
some escaping convention or greedy-matching rule, and that ambiguity would leak
into every route. The project chose the clean routing model and accepted that
Azure-style nested issuers fall outside it.
What makes this a named gap rather than a silent bug is that it is a
deliberate, stated boundary of intent-parity with the upstream
mock-oauth2-server, not an accident of implementation. The mock aims to match
what a provider is for, and for the vast majority of clients the issuer is an
opaque URL to compare byte-for-byte — a single flat segment serves that
perfectly. Nested issuer paths are the corner it does not reproduce, and saying
so plainly is more useful than pretending a request under a nested path did
something sensible. The full inventory of what is and isn't reproduced, and the
reasoning behind each choice, lives in
Parity with mock-oauth2-server.
One related reservation follows the same spirit: the _mock segment is taken by
the control plane, so it cannot be used as an issuer id. A protocol request
under that prefix is refused rather than quietly treated as an issuer — again,
an explicit boundary in place of a silent surprise.
Two questions, two mechanisms¶
The namespace answers who — an isolated, key-bearing trust domain that exists
the moment you name it. The per-request derivation answers where — a URL that
follows the address each caller actually used. Keeping them separate is what
lets one process behave as many issuers and, for each of them, present the
right address to a proxy, a container network, and localhost all at once. When
you are ready to put either mechanism to work, see
Use multiple issuers and
Run behind a proxy or in Docker.