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):

EndpointDefault pathPurpose
Discovery/.well-known/openid-configurationOIDC discovery document
JWKS/.well-known/jwks.jsonPublic signing key(s)
Token/tokenIssues tokens for all supported grants
Authorize/authorizeAuthorization-code grant (issues a code, supports PKCE + nonce)
Userinfo/userinfoReturns the subject and additional claims — requires a valid Authorization: Bearer access token, otherwise 401
Introspection/introspectRFC 7662 token introspection
Revocation/revokeRFC 7009 token revocation — a revoked token then introspects as {"active":false}
End-session (logout)/logoutRP-initiated logout (302 to post_logout_redirect_uri or 200)
Device authorization/device_authorizationRFC 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_tokeniss, sub, aud = clientId, exp, iat, nbf, nonce (when supplied), at_hash, profile/email claims for the requested scopes, plus any additionalClaims. Issued only when the openid scope is requested.
  • access_tokeniss, sub, aud = audience, exp, iat, nbf, scope, client_id, plus any additionalClaims.

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_basicAuthorization: Basic base64(clientId:clientSecret)
  • client_secret_postclient_id and client_secret form 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

FieldDefaultDescription
issuerderived from the Host headerIssuer 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 / deviceAuthorizationPathstandard pathsOverride individual endpoint paths
subjectmock-usersub claim in tokens and userinfo
clientIdmock-clientAudience of the id_token and client_id in the access_token
clientSecretmock-client-secretExpected client secret when enforceClientAuthentication is on
audiencemock-audienceAudience of the access_token
scopes["openid","profile","email"]Default scopes; an id_token is issued only when openid is present
tokenExpirySeconds3600Token lifetime (expires_in / exp)
additionalClaims{}Extra claims merged into the issued tokens and userinfo
signingAlgorithmRS256RS256, RS384, RS512, ES256, ES384, ES512
privateKeyPem + certificatePemgeneratedSupply your own signing key (PEM). The public key is published at the JWKS endpoint
jwkJsongeneratedAlternatively, supply the signing key as a private JWK
keyIdrandomStable kid so JWKS-caching clients keep working across restarts
deviceCodePendingPolls0Device flow: number of /token polls that return authorization_pending before approval (0 = approve immediately)
enforceClientAuthenticationfalseRequire client_secret_basic / client_secret_post at the token endpoint
opaqueAccessTokenfalseIssue an opaque (non-JWT) access_token validated via introspection; id_token stays a signed JWT
issueExpiredTokenfalseNegative test: issue tokens whose exp is in the past
wrongIssuerfalseNegative test: sign tokens with an iss that does not match the discovery document
tamperedSignaturefalseNegative 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.