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 |
| Introspection | /introspect | RFC 7662 token introspection |
| Revocation | /revoke | RFC 7009 token revocation |
| 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
(issuer http://localhost:1080, RS256 signing) 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.
// 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 an opaque token by introspecting it (RFC 7662): a known, unexpired token returns
{"active":true, ...claims} and an unknown or expired token returns {"active":false}.
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 | http://localhost:1080 | Issuer URL; used to build every endpoint URL in the discovery document |
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.