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
Introspection/introspectRFC 7662 token introspection
Revocation/revokeRFC 7009 token revocation
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 (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_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 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

FieldDefaultDescription
issuerhttp://localhost:1080Issuer URL; used to build every endpoint URL in the discovery document
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.