Mock an OIDC / OAuth2 Provider
Mock an OIDC / OAuth2 provider in one call
A single request to PUT /mockserver/oidc generates a complete mock OpenID Connect / OAuth2
identity provider. MockServer creates expectations for every standard endpoint and signs all tokens with a
key pair whose public key is published at the JWKS endpoint, so the tokens your application receives
verify end-to-end against the discovery document — no real identity provider required.
This is ideal for:
- Integration tests for OAuth2/OIDC-secured applications and resource servers
- Offline development against a realistic IdP with no network dependency
- Edge-case testing — expired tokens, wrong issuer, tampered signatures
Generated endpoints
A single call generates the following expectations (paths are configurable):
| Endpoint | Default path | Purpose |
|---|---|---|
| Discovery | /.well-known/openid-configuration | OIDC discovery document |
| JWKS | /.well-known/jwks.json | Public signing key(s) |
| Token | /token | Issues tokens for all supported grants |
| Authorize | /authorize | Authorization-code grant (issues a code, supports PKCE + nonce) |
| Userinfo | /userinfo | Returns the subject and additional claims — requires a valid Authorization: Bearer access token, otherwise 401 |
| Introspection | /introspect | RFC 7662 token introspection |
| Revocation | /revoke | RFC 7009 token revocation — a revoked token then introspects as {"active":false} |
| End-session (logout) | /logout | RP-initiated logout (302 to post_logout_redirect_uri or 200) |
| Device authorization | /device_authorization | RFC 8628 device-code grant (issues a device_code + user_code) |
Create the provider
A single control-plane call to PUT /mockserver/oidc generates the whole provider. Use defaults
(RS256 signing, standard endpoint paths) with an empty body, or supply configuration. The
Java client exposes a typed one-call method; every other language sends the JSON body to the
/mockserver/oidc endpoint.
The issuer is derived automatically from the address you call the provider on. If you do
not set issuer, MockServer builds it per request from the Host header — so a
provider running on a random or container-mapped port (Testcontainers, Docker) advertises the URL your
client actually used. This matters because the OpenID Connect specification requires the
issuer in the discovery document to match the URL the document was fetched from, and
libraries such as Spring Security, Nimbus and pac4j will refuse to start if it does not. Set
issuer explicitly only when you need a fixed, externally-meaningful value.
// defaults
new MockServerClient("localhost", 1080).mockOpenIdProvider();
// or with configuration
new MockServerClient("localhost", 1080).mockOpenIdProvider(
new OidcProviderConfiguration()
.setIssuer("http://localhost:1080")
.setClientId("my-app")
.setAudience("my-api")
.setScopes(List.of("openid", "profile", "email"))
.setSigningAlgorithm("RS256")
.setTokenExpirySeconds(3600)
.setAdditionalClaims(Map.of(
"name", "Test User",
"email", "user@example.com",
"email_verified", true
))
);
const http = require('http');
const body = JSON.stringify({
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": [ "openid", "profile", "email" ],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": true
}
});
const req = http.request({
host: 'localhost',
port: 1080,
method: 'PUT',
path: '/mockserver/oidc',
headers: { 'Content-Type': 'application/json' }
});
req.write(body);
req.end();
import json
import urllib.request
body = json.dumps({
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": ["openid", "profile", "email"],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": True
}
}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:1080/mockserver/oidc",
data=body,
method="PUT",
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req)
require 'net/http'
require 'json'
require 'uri'
uri = URI('http://localhost:1080/mockserver/oidc')
req = Net::HTTP::Put.new(uri, 'Content-Type' => 'application/json')
req.body = {
issuer: 'http://localhost:1080',
clientId: 'my-app',
audience: 'my-api',
scopes: ['openid', 'profile', 'email'],
signingAlgorithm: 'RS256',
tokenExpirySeconds: 3600,
additionalClaims: {
name: 'Test User',
email: 'user@example.com',
email_verified: true
}
}.to_json
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
package main
import (
"bytes"
"net/http"
)
func main() {
body := []byte(`{
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": [ "openid", "profile", "email" ],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": true
}
}`)
req, _ := http.NewRequest("PUT",
"http://localhost:1080/mockserver/oidc",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
}
using System.Net.Http;
using System.Text;
using var httpClient = new HttpClient();
var json = @"{
""issuer"": ""http://localhost:1080"",
""clientId"": ""my-app"",
""audience"": ""my-api"",
""scopes"": [ ""openid"", ""profile"", ""email"" ],
""signingAlgorithm"": ""RS256"",
""tokenExpirySeconds"": 3600,
""additionalClaims"": {
""name"": ""Test User"",
""email"": ""user@example.com"",
""email_verified"": true
}
}";
await httpClient.PutAsync(
"http://localhost:1080/mockserver/oidc",
new StringContent(json, Encoding.UTF8, "application/json")
);
use reqwest::blocking::Client;
let client = Client::new();
let body = r#"{
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": [ "openid", "profile", "email" ],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": true
}
}"#;
client.put("http://localhost:1080/mockserver/oidc")
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.unwrap();
$json = <<<'JSON'
{
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": [ "openid", "profile", "email" ],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": true
}
}
JSON;
$ch = curl_init('http://localhost:1080/mockserver/oidc');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
# defaults
curl -v -X PUT "http://localhost:1080/mockserver/oidc" -d '{}'
# or with configuration
curl -v -X PUT "http://localhost:1080/mockserver/oidc" -d '{
"issuer": "http://localhost:1080",
"clientId": "my-app",
"audience": "my-api",
"scopes": [ "openid", "profile", "email" ],
"signingAlgorithm": "RS256",
"tokenExpirySeconds": 3600,
"additionalClaims": {
"name": "Test User",
"email": "user@example.com",
"email_verified": true
}
}'
Token shapes
Tokens are minted at request time, so the nonce from an /authorize request is
echoed into the id_token. The two tokens are kept distinct, per the OIDC core spec:
id_token—iss,sub,aud = clientId,exp,iat,nbf,nonce(when supplied),at_hash, profile/email claims for the requested scopes, plus anyadditionalClaims. Issued only when theopenidscope is requested.access_token—iss,sub,aud = audience,exp,iat,nbf,scope,client_id, plus anyadditionalClaims.
The token endpoint returns a refresh_token for the authorization_code,
refresh_token, and device-code grants. The discovery document advertises the implemented grants
(authorization_code, client_credentials, refresh_token,
urn:ietf:params:oauth:grant-type:device_code), the PKCE methods (S256,
plain), the token-endpoint auth methods, the end_session_endpoint, and the
device_authorization_endpoint.
Device authorization grant (RFC 8628)
The device flow lets input-constrained devices (TVs, CLIs) obtain tokens. Start it at the
/device_authorization endpoint, then poll /token with the returned
device_code:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient httpClient = HttpClient.newHttpClient();
HttpResponse<String> post(String path, String form) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:1080" + path))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
// 1. start the device flow
post("/device_authorization", "scope=openid profile");
// => { "device_code": "...", "user_code": "BCDF-GHJK",
// "verification_uri": "http://localhost:1080/device",
// "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
post("/token", "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=...");
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
const http = require('http');
function post(path, form) {
const body = new URLSearchParams(form).toString();
const req = http.request({
host: 'localhost', port: 1080, method: 'POST', path,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
req.write(body);
req.end();
}
// 1. start the device flow
post('/device_authorization', { scope: 'openid profile' });
// => { "device_code": "...", "user_code": "BCDF-GHJK",
// "verification_uri": "http://localhost:1080/device",
// "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
post('/token', {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: '...'
});
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
import urllib.parse
import urllib.request
def post(path, form):
data = urllib.parse.urlencode(form).encode("utf-8")
req = urllib.request.Request(
"http://localhost:1080" + path, data=data, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
return urllib.request.urlopen(req)
# 1. start the device flow
post("/device_authorization", {"scope": "openid profile"})
# => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
# 2. poll the token endpoint with the device_code
post("/token", {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "..."
})
# => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
# => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
require 'net/http'
require 'uri'
def post(path, form)
uri = URI('http://localhost:1080' + path)
req = Net::HTTP::Post.new(uri)
req.set_form_data(form)
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end
# 1. start the device flow
post('/device_authorization', { 'scope' => 'openid profile' })
# => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
# 2. poll the token endpoint with the device_code
post('/token', {
'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
'device_code' => '...'
})
# => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
# => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
package main
import (
"net/http"
"net/url"
)
func post(path string, form url.Values) {
http.PostForm("http://localhost:1080"+path, form)
}
func main() {
// 1. start the device flow
post("/device_authorization", url.Values{"scope": {"openid profile"}})
// => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
post("/token", url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {"..."},
})
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
}
using System.Collections.Generic;
using System.Net.Http;
using var httpClient = new HttpClient();
// 1. start the device flow
await httpClient.PostAsync(
"http://localhost:1080/device_authorization",
new FormUrlEncodedContent(new Dictionary<string, string> {
{ "scope", "openid profile" }
})
);
// => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
await httpClient.PostAsync(
"http://localhost:1080/token",
new FormUrlEncodedContent(new Dictionary<string, string> {
{ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" },
{ "device_code", "..." }
})
);
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
use reqwest::blocking::Client;
let client = Client::new();
// 1. start the device flow
client.post("http://localhost:1080/device_authorization")
.form(&[("scope", "openid profile")])
.send()
.unwrap();
// => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
client.post("http://localhost:1080/token")
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", "..."),
])
.send()
.unwrap();
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
function post($path, $form) {
$ch = curl_init('http://localhost:1080' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($form),
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
curl_close($ch);
return $body;
}
// 1. start the device flow
post('/device_authorization', ['scope' => 'openid profile']);
// => { "device_code": "...", "user_code": "BCDF-GHJK", "expires_in": 300, "interval": 5 }
// 2. poll the token endpoint with the device_code
post('/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
'device_code' => '...',
]);
// => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
// => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
# 1. start the device flow
curl -s -X POST "http://localhost:1080/device_authorization" -d 'scope=openid profile'
# => { "device_code": "...", "user_code": "BCDF-GHJK",
# "verification_uri": "http://localhost:1080/device",
# "verification_uri_complete": "http://localhost:1080/device?user_code=BCDF-GHJK",
# "expires_in": 300, "interval": 5 }
# 2. poll the token endpoint with the device_code
curl -s -X POST "http://localhost:1080/token" \
-d 'grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=...'
# => {"error":"authorization_pending"} (HTTP 400) for the first deviceCodePendingPolls polls
# => { "access_token": "...", "id_token": "...", "refresh_token": "..." } once approved
The mock has no device login screen, so approval is simulated by the deviceCodePendingPolls
setting: the first N polls return RFC 8628 authorization_pending (HTTP 400), then tokens are
minted. The default 0 approves immediately on the first poll. A device code is single-use once
approved and expires after expires_in seconds; an unknown, already-redeemed, or expired
device_code returns expired_token.
Token-endpoint client authentication
By default the mock /token endpoint accepts any caller (so tests stay simple). Set
enforceClientAuthentication to true to require the client to authenticate with the
configured clientId / clientSecret, using either standard method:
client_secret_basic—Authorization: Basic base64(clientId:clientSecret)client_secret_post—client_idandclient_secretform parameters
Missing or wrong credentials return RFC 6749 §5.2 {"error":"invalid_client"} with HTTP
401 and a WWW-Authenticate: Basic header. With the flag off (the default), no client
authentication is performed.
Create the provider with client authentication enforced:
new MockServerClient("localhost", 1080).mockOpenIdProvider(
new OidcProviderConfiguration()
.setClientId("my-app")
.setClientSecret("my-secret")
.setEnforceClientAuthentication(true)
);
const http = require('http');
const body = JSON.stringify({
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": true
});
const req = http.request({
host: 'localhost',
port: 1080,
method: 'PUT',
path: '/mockserver/oidc',
headers: { 'Content-Type': 'application/json' }
});
req.write(body);
req.end();
import json
import urllib.request
body = json.dumps({
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": True
}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:1080/mockserver/oidc",
data=body,
method="PUT",
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req)
require 'net/http'
require 'json'
require 'uri'
uri = URI('http://localhost:1080/mockserver/oidc')
req = Net::HTTP::Put.new(uri, 'Content-Type' => 'application/json')
req.body = {
clientId: 'my-app',
clientSecret: 'my-secret',
enforceClientAuthentication: true
}.to_json
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
package main
import (
"bytes"
"net/http"
)
func main() {
body := []byte(`{
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": true
}`)
req, _ := http.NewRequest("PUT",
"http://localhost:1080/mockserver/oidc",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
}
using System.Net.Http;
using System.Text;
using var httpClient = new HttpClient();
var json = @"{
""clientId"": ""my-app"",
""clientSecret"": ""my-secret"",
""enforceClientAuthentication"": true
}";
await httpClient.PutAsync(
"http://localhost:1080/mockserver/oidc",
new StringContent(json, Encoding.UTF8, "application/json")
);
use reqwest::blocking::Client;
let client = Client::new();
let body = r#"{
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": true
}"#;
client.put("http://localhost:1080/mockserver/oidc")
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.unwrap();
$json = <<<'JSON'
{
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": true
}
JSON;
$ch = curl_init('http://localhost:1080/mockserver/oidc');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
curl -v -X PUT "http://localhost:1080/mockserver/oidc" -d '{
"clientId": "my-app",
"clientSecret": "my-secret",
"enforceClientAuthentication": true
}'
Opaque access tokens
Many real IdPs issue opaque access tokens — random reference strings that are not JWTs — whose only
validation path is the introspection endpoint. Set opaqueAccessToken to true to
mock this: the access_token becomes a random opaque string while the id_token
stays a signed JWT that still verifies against the JWKS.
Create the provider with opaque access tokens:
new MockServerClient("localhost", 1080).mockOpenIdProvider(
new OidcProviderConfiguration()
.setOpaqueAccessToken(true)
);
const http = require('http');
const body = JSON.stringify({ "opaqueAccessToken": true });
const req = http.request({
host: 'localhost',
port: 1080,
method: 'PUT',
path: '/mockserver/oidc',
headers: { 'Content-Type': 'application/json' }
});
req.write(body);
req.end();
import json
import urllib.request
body = json.dumps({"opaqueAccessToken": True}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:1080/mockserver/oidc",
data=body,
method="PUT",
headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req)
require 'net/http'
require 'json'
require 'uri'
uri = URI('http://localhost:1080/mockserver/oidc')
req = Net::HTTP::Put.new(uri, 'Content-Type' => 'application/json')
req.body = { opaqueAccessToken: true }.to_json
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
package main
import (
"bytes"
"net/http"
)
func main() {
body := []byte(`{ "opaqueAccessToken": true }`)
req, _ := http.NewRequest("PUT",
"http://localhost:1080/mockserver/oidc",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
}
using System.Net.Http;
using System.Text;
using var httpClient = new HttpClient();
var json = @"{ ""opaqueAccessToken"": true }";
await httpClient.PutAsync(
"http://localhost:1080/mockserver/oidc",
new StringContent(json, Encoding.UTF8, "application/json")
);
use reqwest::blocking::Client;
let client = Client::new();
let body = r#"{ "opaqueAccessToken": true }"#;
client.put("http://localhost:1080/mockserver/oidc")
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.unwrap();
$json = '{ "opaqueAccessToken": true }';
$ch = curl_init('http://localhost:1080/mockserver/oidc');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
curl -v -X PUT "http://localhost:1080/mockserver/oidc" -d '{
"opaqueAccessToken": true
}'
Validate a token by introspecting it (RFC 7662): a valid, unexpired token returns
{"active":true, ...claims} and anything else returns {"active":false}.
Introspection checks the token you present. This works for both token types: an opaque
token is looked up by reference, and a JWT is verified against the provider's signing key and its own
expiry. A token that is unknown, expired, tampered with, revoked, or issued by a different provider
reports {"active":false} — and an inactive response contains nothing but
active, so it cannot leak claims about a token the caller does not hold. A request with no
token parameter returns 400 invalid_request.
Behaviour change. Introspection previously ignored the presented token when
the provider issued JWTs (the default) and answered from static configuration, so any string reported
active:true. The /userinfo endpoint likewise served claims to any caller without
checking for a bearer token. If a test of yours starts failing after upgrading — for example one asserting
that your application rejects an expired or revoked token, or that it handles a 401 from
/userinfo — that test was previously passing without exercising the behaviour it claimed to
cover. See the changelog for the release this lands in.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:1080/introspect"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString("token=<opaque-access-token>"))
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
const http = require('http');
const body = new URLSearchParams({ token: '<opaque-access-token>' }).toString();
const req = http.request({
host: 'localhost',
port: 1080,
method: 'POST',
path: '/introspect',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
req.write(body);
req.end();
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
import urllib.parse
import urllib.request
data = urllib.parse.urlencode({"token": "<opaque-access-token>"}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:1080/introspect",
data=data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
urllib.request.urlopen(req)
# => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
require 'net/http'
require 'uri'
uri = URI('http://localhost:1080/introspect')
req = Net::HTTP::Post.new(uri)
req.set_form_data('token' => '<opaque-access-token>')
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
# => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
package main
import (
"net/http"
"net/url"
)
func main() {
http.PostForm(
"http://localhost:1080/introspect",
url.Values{"token": {"<opaque-access-token>"}},
)
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
}
using System.Collections.Generic;
using System.Net.Http;
using var httpClient = new HttpClient();
await httpClient.PostAsync(
"http://localhost:1080/introspect",
new FormUrlEncodedContent(new Dictionary<string, string> {
{ "token", "<opaque-access-token>" }
})
);
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
use reqwest::blocking::Client;
let client = Client::new();
client.post("http://localhost:1080/introspect")
.form(&[("token", "<opaque-access-token>")])
.send()
.unwrap();
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
$ch = curl_init('http://localhost:1080/introspect');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['token' => '<opaque-access-token>']),
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
// => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
curl -s -X POST "http://localhost:1080/introspect" -d 'token=<opaque-access-token>'
# => { "active": true, "sub": "mock-user", "scope": "openid profile email", ... }
Configuration
| Field | Default | Description |
|---|---|---|
issuer | derived from the Host header | Issuer URL; used to build every endpoint URL in the discovery document. Leave unset so it matches whatever address the client reaches the provider on (required by the spec, and essential on random/mapped ports); set it to pin a fixed value. X-Forwarded-Proto is honoured behind a TLS-terminating proxy. |
jwksPath / tokenPath / authorizePath / userinfoPath / introspectPath / revokePath / deviceAuthorizationPath | standard paths | Override individual endpoint paths |
subject | mock-user | sub claim in tokens and userinfo |
clientId | mock-client | Audience of the id_token and client_id in the access_token |
clientSecret | mock-client-secret | Expected client secret when enforceClientAuthentication is on |
audience | mock-audience | Audience of the access_token |
scopes | ["openid","profile","email"] | Default scopes; an id_token is issued only when openid is present |
tokenExpirySeconds | 3600 | Token lifetime (expires_in / exp) |
additionalClaims | {} | Extra claims merged into the issued tokens and userinfo |
signingAlgorithm | RS256 | RS256, RS384, RS512, ES256, ES384, ES512 |
privateKeyPem + certificatePem | generated | Supply your own signing key (PEM). The public key is published at the JWKS endpoint |
jwkJson | generated | Alternatively, supply the signing key as a private JWK |
keyId | random | Stable kid so JWKS-caching clients keep working across restarts |
deviceCodePendingPolls | 0 | Device flow: number of /token polls that return authorization_pending before approval (0 = approve immediately) |
enforceClientAuthentication | false | Require client_secret_basic / client_secret_post at the token endpoint |
opaqueAccessToken | false | Issue an opaque (non-JWT) access_token validated via introspection; id_token stays a signed JWT |
issueExpiredToken | false | Negative test: issue tokens whose exp is in the past |
wrongIssuer | false | Negative test: sign tokens with an iss that does not match the discovery document |
tamperedSignature | false | Negative test: corrupt the signature so verification fails |
When the default (generated) key is used, the key pair is fresh per call, so a client that has
cached the JWKS before re-generating the provider will see a new kid. Supply a fixed
keyId (and key material) for a stable JWKS across restarts.
Spring Security example
Point a Spring Security resource server (or OAuth2 login client) at the mock provider's issuer; Spring discovers the endpoints and JWKS automatically:
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:1080
Because the issued tokens verify against the published JWKS, Spring Security validates them with no further
configuration. For the OAuth2 login (authorization-code) flow, the mock /authorize endpoint
issues a code (echoing state and recording any PKCE challenge and nonce), and the
/token endpoint exchanges it for tokens carrying the nonce.
Related pages
This page covers the turnkey provider — a complete IdP in one call with verifiable tokens. If instead you want to hand-roll individual OAuth2/OIDC expectations (discovery, JWKS, userinfo, PKCE, bearer-token recipes) for fine-grained control, see Mocking OAuth2 Flows.