mock-oauth2-server
Scriptable OAuth2/OpenID Connect server for JVM tests and Docker Compose.
[!NOTE] The latest stable version is shown in the Maven Central badge above. See the release notes for what has changed.
Table of Contents
- Quick Start
- What it does
- Supported Flows
- Usage
- Configuration Reference
- API Reference
- Migration guide
- Contributing
- Contact
- License
Quick Start
Add the dependency:
Gradle Kotlin DSL
testImplementation("no.nav.security:mock-oauth2-server:$mockOAuth2ServerVersion")Maven
<dependency>
<groupId>no.nav.security</groupId>
<artifactId>mock-oauth2-server</artifactId>
<version>${mock-oauth2-server.version}</version>
<scope>test</scope>
</dependency>Start the server and issue a token in your test:
val server = MockOAuth2Server()
server.start()
val token = server.issueToken(
issuerId = "default",
subject = "user123",
audience = "my-api",
)
// Point your app at the discovery URL
val wellKnownUrl = server.wellKnownUrl("default").toString()
// Attach the token to a request
request.addHeader("Authorization", "Bearer ${token.serialize()}")
server.shutdown()Or run it as a Docker container:
docker run -p 8080:8080 ghcr.io/navikt/mock-oauth2-server:$MOCK_OAUTH2_SERVER_VERSION
Token endpoint: http://localhost:8080/default/token
Discovery: http://localhost:8080/default/.well-known/openid-configuration
What it does
mock-oauth2-server lets you test applications that depend on a real OAuth2/OpenID Connect server without disabling security. It issues signed JWTs that are verifiable through standard JWKS and discovery endpoints, so your app does not need any special test configuration.
It supports multi-issuer setups, all major OAuth2 grant types, token customization, and runs both embedded in JVM tests and as a standalone process in Docker Compose.
[!WARNING] This server is for testing only. Do not use it in production.
Supported Flows
- OpenID Connect Authorization Code Flow
- OAuth2 Client Credentials Grant
- OAuth2 JWT Bearer Grant (On-Behalf-Of flow)
- OAuth2 Token Exchange Grant
- OAuth2 Refresh Token Grant
- OAuth2 Resource Owner Password Credentials Grant
- usage should be avoided if possible as this grant is considered insecure and removed in its entirety from OAuth 2.1
Issued JWT tokens are verifiable through standard mechanisms via OpenID Connect Discovery and OAuth2 Authorization Server Metadata endpoints. Multi-issuer setups are supported with no configuration — the first path segment in any request URL determines the issuer.
Usage
In JVM Tests
Minimal setup
val server = MockOAuth2Server()
server.start()
val wellKnownUrl = server.wellKnownUrl("default").toString()
// configure your app to use wellKnownUrl, run your test, then:
server.shutdown()Use withMockOAuth2Server to avoid manual start/shutdown:
withMockOAuth2Server {
val wellKnownUrl = wellKnownUrl("default").toString()
// configure your app and run your test here
}Issuing tokens directly
The simplest way to issue a token:
val token: SignedJWT = server.issueToken(
issuerId = "default",
subject = "user123",
audience = "my-api",
claims = mapOf("roles" to listOf("admin")),
expiry = 3600,
)
request.addHeader("Authorization", "Bearer ${token.serialize()}")To issue a token with a custom callback object for full control:
val token: SignedJWT = server.issueToken(issuerId, "someclientid", DefaultOAuth2TokenCallback())To issue a token for an external issuer URL that is still verifiable via this server's JWKS:
val token: SignedJWT = server.anyToken(
issuerUrl = "https://external-idp.example.com".toHttpUrl(),
claims = mapOf("sub" to "user123", "aud" to "my-api"),
)Testing Authorization Code Flow (user login)
Enqueue a callback to control what is returned when your app exchanges the code:
@Test
fun loginWithIdTokenForSubjectFoo() {
server.enqueueCallback(
DefaultOAuth2TokenCallback(
issuerId = issuerId,
subject = "foo"
)
)
// Invoke your app here and assert user foo is logged in
}To set specific claims in the id_token:
@Test
fun loginWithIdTokenForAcrClaimEqualsLevel4() {
server.enqueueCallback(
DefaultOAuth2TokenCallback(
issuerId = issuerId,
claims = mapOf("acr" to "Level4")
)
)
// Invoke your app here and assert acr=Level4 is present in id_token
}Verifying requests made to the server
[!NOTE]
takeRequest()is only available when usingMockWebServerWrapper(the default). It throwsUnsupportedOperationExceptionwithNettyWrapper.
val request = server.takeRequest()
assertThat(request.path).contains("/default/token")Controlling token time
val server = MockOAuth2Server(
config = OAuth2Config(
tokenProvider = OAuth2TokenProvider(systemTime = Instant.parse("2020-01-21T00:00:00Z"))
)
)
val token = server.issueToken(issuerId = "issuer1")
// token has iat=2020-01-21T00:00:00ZMulti-issuer setup
The first path segment in any request URL is the issuerId. No configuration needed:
http://localhost:8080/issuer-a/.well-known/openid-configuration → issuerId = issuer-a
http://localhost:8080/issuer-b/.well-known/openid-configuration → issuerId = issuer-b
Each issuer has its own discovery document, token endpoint, and JWKS.
More examples
Standalone / Docker
The standalone server defaults to port 8080.
Run with Docker:
docker run -p 8080:8080 ghcr.io/navikt/mock-oauth2-server:$MOCK_OAUTH2_SERVER_VERSION
Images are also tagged by major and minor version, so you can pin to a release stream:
| Tag | Resolves to |
|---|---|
ghcr.io/navikt/mock-oauth2-server:3.1.4 | exact version |
ghcr.io/navikt/mock-oauth2-server:3.1 | latest 3.1.x |
ghcr.io/navikt/mock-oauth2-server:3 | latest 3.x.x |
Build locally:
./gradlew -Djib.from.platforms=linux/amd64 jibDockerBuild
docker run -p 8080:8080 $IMAGE_NAME
Health check: GET /isalive returns 200 when the server is ready.
On Windows, specify the host explicitly: docker run -p 8080:8080 -h localhost $IMAGE_NAME
Docker Compose
When running mock-oauth2-server alongside your application in Docker Compose, there are two networking scenarios to consider.
Scenario 1: Container-to-container only (most common for integration tests)
Both services communicate over Docker's internal network. Your app references the mock server using the Docker Compose service name as the hostname:
services:
your_app:
build: .
ports:
- 8080:8080
environment:
- SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://mock-oauth2-server:8080/default/jwks
mock-oauth2-server:
image: ghcr.io/navikt/mock-oauth2-server:$MOCK_OAUTH2_SERVER_VERSION
ports:
- 8090:8080Your app reaches the mock server at http://mock-oauth2-server:8080 (internal Docker network). From your host machine the mock server is at http://localhost:8090.
Scenario 2: Container-to-container + browser interaction (e.g. Authorization Code Flow)
If a browser also needs to reach the mock server, issuer URLs in tokens must be resolvable both from inside Docker and from your browser:
- Add
127.0.0.1 host.docker.internalto your/etc/hostsfile (Linux only; macOS and Windows Docker Desktop add this automatically). - Set
hostname: host.docker.internalon the mock server service.
services:
your_app:
build: .
ports:
- 8080:8080
environment:
- SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://host.docker.internal:8090/default/jwks
mock-oauth2-server:
image: ghcr.io/navikt/mock-oauth2-server:$MOCK_OAUTH2_SERVER_VERSION
ports:
- 8090:8080
hostname: host.docker.internal[!NOTE] Each service must use a different host port. Mapping two services to the same host port causes a
port is already allocatederror.
Token Customization via JSON_CONFIG
When running standalone or in Docker you can configure token callbacks via the JSON_CONFIG environment variable (or JSON_CONFIG_PATH pointing to a file). When neither is set the server looks for config.json in the current working directory.
A token callback lets you define what claims are returned when a token request matches a given parameter:
{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "issuer1",
"tokenExpiry": 120,
"requestMappings": [
{
"requestParam": "code",
"match": "*",
"claims": {
"sub": "subByCode",
"aud": ["audByCode"]
}
}
]
},
{
"issuerId": "issuer2",
"requestMappings": [
{
"requestParam": "someparam",
"match": "somevalue",
"claims": {
"sub": "subBySomeParam",
"aud": ["audBySomeParam"]
}
}
]
}
]
}A token request to http://localhost:8080/issuer1/token with any code parameter will match the first tokenCallback and return a token with:
{
"sub": "subByCode",
"aud": ["audByCode"],
"iss": "http://localhost:8080/issuer1",
...
}The match field supports exact strings, "*" (matches any value), and full regular expressions. If the pattern is an invalid regular expression, it does not throw — regex evaluation is skipped, but exact-string matching still applies.
Use ${clientId} (or ${client_id}) in claim values to insert the requesting client ID dynamically. All form parameters from the token request are available as template variables:
{
"issuerId": "issuer1",
"requestMappings": [
{
"requestParam": "code",
"match": "*",
"claims": {
"sub": "${clientId}",
"aud": ["audByCode"]
}
}
]
}Built-in template variables
In addition to form parameters, the following built-in variables are always available:
| Variable | Value |
|---|---|
${clientId} / ${client_id} | The client_id from the token request |
Interactive login: matching and templating on the login username
When interactiveLogin is enabled, requestMappings can match on the username submitted at the login page using "requestParam": "subject". The login username is also available as ${subject} in claim values.
This allows a single JSON_CONFIG to serve different claim sets per user without a custom login page:
{
"interactiveLogin": true,
"tokenCallbacks": [
{
"issuerId": "default",
"requestMappings": [
{
"requestParam": "subject",
"match": "alice",
"claims": {
"role": "admin",
"preferred_username": "${subject}"
}
},
{
"requestParam": "subject",
"match": ".*",
"claims": {
"role": "user",
"preferred_username": "${subject}"
}
}
]
}
]
}Claim precedence when combining requestMappings with interactive login:
- Claims set by a matching
requestMappingtake priority. - Claims submitted on the login page can add new claims but cannot overwrite claims already set by the mapping.
Template variable precedence (highest wins):
client_id/clientId— always authoritative- Token POST body form parameters
${subject}and other built-in variables
Auto-added claims
Every token issued by DefaultOAuth2TokenCallback automatically includes the following claims regardless of what you configure:
| Claim | Value | When |
|---|---|---|
tid | the issuerId (e.g. "default") | always |
azp | the client_id from the token request | Authorization Code grant only |
You can override tid by including it in your claims map. azp cannot be overridden for Authorization Code grants as it is added after your claims are applied.
aud claim resolution
When using DefaultOAuth2TokenCallback the aud claim is resolved in the following order:
- The
audiencevalue passed explicitly to the constructor - The
audienceparameter from the token request (e.g. in Token Exchange) - The non-OIDC scopes from the request (e.g. for Client Credentials with scopes)
- Falls back to
"default"if none of the above are present
HTTPS
In unit tests
Generate a keystore automatically:
val ssl = Ssl()
val server = MockOAuth2Server(
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
)[!TIP] Add
ssl.sslKeystore.keyStoreto your client's truststore to trust the generated certificate.
Bring your own keystore:
val ssl = Ssl(
SslKeystore(
keyPassword = "",
keystoreFile = File("src/test/resources/localhost.p12"),
keystorePassword = "",
keystoreType = SslKeystore.KeyStoreType.PKCS12
)
)
val server = MockOAuth2Server(
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
)In Docker / standalone via JSON_CONFIG
Generate keystore:
{
"httpServer": {
"type": "NettyWrapper",
"ssl": {}
}
}Bring your own:
{
"httpServer": {
"type": "NettyWrapper",
"ssl": {
"keyPassword": "",
"keystoreFile": "src/test/resources/localhost.p12",
"keystoreType": "PKCS12",
"keystorePassword": ""
}
}
}A ready to use Docker Compose setup with SSL is available at docker-compose-ssl.yaml in the root of this repository.
CORS
The server automatically adds CORS headers to every response when an Origin header is present. No configuration is required.
For regular requests:
Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Credentials: true
For OPTIONS preflight requests:
Access-Control-Allow-Headers: <requested-headers>
Access-Control-Allow-Methods: POST, GET, OPTIONS
Browser based OAuth2 clients and SPAs can call the token, JWKS and other endpoints directly without any proxy setup.
Debugger
Point your browser to http://localhost:8080/default/debugger to open the OAuth2 client debugger. It implements the Authorization Code Flow and lets you inspect request parameters and token responses interactively.
Configuration Reference
Standalone ENV variables
| Variable | Description |
|---|---|
SERVER_HOSTNAME | Hostname to bind to. Defaults to the wildcard address (typically 0.0.0.0 or :: depending on the JVM and OS). |
SERVER_PORT or PORT | Port to listen on. Defaults to 8080. PORT is also accepted for Heroku compatibility. |
JSON_CONFIG | Full JSON content of OAuth2Config. Takes precedence over JSON_CONFIG_PATH. |
JSON_CONFIG_PATH | Absolute path to a JSON file containing OAuth2Config. |
LOG_LEVEL | Root log level. Defaults to INFO. |
LOGBACK_CONFIG | Path to a custom logback XML file. Overrides the default logback-standalone.xml. |
When neither JSON_CONFIG nor JSON_CONFIG_PATH is set, the server looks for a file named config.json in the current working directory before falling back to defaults.
JSON_CONFIG properties
| Property | Description |
|---|---|
interactiveLogin | true or false. Enables a login screen on the /authorize endpoint. Defaults to true in standalone mode, false in library mode. |
loginPagePath | Path to a custom HTML login page. The page must contain a form that posts username and optionally claims. See src/test/resources/login.example.html for an example. |
staticAssetsPath | Path to a directory of static assets served under /static. E.g. http://localhost:8080/static/myimage.svg. |
rotateRefreshToken | true or false. When true, a new refresh token is issued on each refresh grant, invalidating the previous one. |
httpServer | The HTTP server implementation to use: MockWebServerWrapper (default, supports takeRequest()) or NettyWrapper (required for HTTPS). Can also be a JSON object: {"type": "NettyWrapper", "ssl": {...}}. |
tokenCallbacks | A list of RequestMappingTokenCallback objects that define which claims to return based on request parameters. |
Additional token provider options:
{
"tokenProvider": {
"keyProvider": {
"algorithm": "ES256"
},
"systemTime": "2020-01-21T00:00:00Z"
}
}API Reference
Well-known endpoints
The first path segment in any URL is the issuerId. Both OIDC and OAuth2 AS metadata endpoints are served:
GET /{issuerId}/.well-known/openid-configuration
GET /{issuerId}/.well-known/oauth-authorization-server
View example response for http://localhost:8080/default/.well-known/openid-configuration
{
"issuer": "http://localhost:8080/default",
"authorization_endpoint": "http://localhost:8080/default/authorize",
"token_endpoint": "http://localhost:8080/default/token",
"jwks_uri": "http://localhost:8080/default/jwks",
"userinfo_endpoint": "http://localhost:8080/default/userinfo",
"introspection_endpoint": "http://localhost:8080/default/introspect",
"revocation_endpoint": "http://localhost:8080/default/revoke",
"end_session_endpoint": "http://localhost:8080/default/endsession"
}Endpoint notes
Introspect (POST /{issuerId}/introspect) requires an Authorization header. Either Authorization: Bearer <token> or Authorization: Basic <credentials> is accepted. Requests without it will receive 400 invalid_client.
Revocation (POST /{issuerId}/revoke) only supports token_type_hint=refresh_token. Passing any other value returns 400 unsupported_token_type.
Server URL methods (Kotlin/Java API)
server.wellKnownUrl("default") // OIDC discovery URL
server.oauth2AuthorizationServerMetadataUrl("default") // OAuth2 AS metadata URL
server.tokenEndpointUrl("default")
server.jwksUrl("default")
server.userInfoUrl("default")
server.introspectUrl("default")
server.revocationEndpointUrl("default")
server.endSessionEndpointUrl("default")
server.baseUrl() // server root URLFull API documentation
navikt.github.io/mock-oauth2-server
Contact
This project is maintained by @navikt.
To raise an issue or question, open an issue in this repository.
For internal NAV contact, use the Slack channel #nais.
See the release notes for the full version history.
Contributing
Fork the repo, check out a new branch, and build with:
./gradlew buildSee CONTRIBUTING.md for more details.
License
This library is licensed under the MIT License.
Migration guide
See MIGRATION.md for upgrade instructions between versions.