- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 1. SSO/OAuth2/OIDC Fundamentals Review
- 2. Why SSO Mocking Is Necessary
- 3. Method 1: mock-oauth2-server (NAV)
- 4. Method 2: OIDC Provider Simulation with WireMock
- 5. Method 3: Keycloak Test Containers
- 6. Method 4: Spring Security Test
- 7. Method 5: JWT Signing with jose in Node.js
- 8. Method 6: Python authlib Mock
- 9. Practical Tips
- 10. CI/CD Integration Example
- 11. Tool Selection Guide
- 12. Conclusion
1. SSO/OAuth2/OIDC Fundamentals Review
1.1 SSO (Single Sign-On)
SSO is an authentication mechanism that allows access to multiple services with a single authentication. Once a user logs in at the IdP (Identity Provider), they can access all connected SPs (Service Providers) without additional authentication.
┌──────┐ 1. Login ┌──────────┐
│ User │ ───────────────> │ IdP │
└──┬───┘ │(Keycloak)│
│ └────┬─────┘
│ 2. Token issued │
│ <──────────────────────────┘
│
│ 3. Access with token
├──────────────────> [Service A]
├──────────────────> [Service B]
└──────────────────> [Service C]
1.2 OAuth 2.0 Authorization Code Flow
The most common OAuth 2.0 flow is the Authorization Code Flow.
┌──────┐ ┌──────────┐ ┌──────────┐
│Client│ │ AuthZ │ │ Resource │
│(App) │ │ Server │ │ Server │
└──┬───┘ └────┬─────┘ └────┬─────┘
│ 1. /authorize │ │
│────────────────────────────>│ │
│ │ 2. User authentication │
│ │ (login page) │
│ 3. Authorization Code │ │
│<────────────────────────────│ │
│ │ │
│ 4. POST /token │ │
│ (code + client_secret) │ │
│────────────────────────────>│ │
│ │ │
│ 5. Access Token + ID Token │ │
│<────────────────────────────│ │
│ │ │
│ 6. API call (Bearer Token) │ │
│─────────────────────────────┼──────────────────────────────>│
│ │ │
│ 7. Resource response │ │
│<────────────────────────────┼───────────────────────────────│
1.3 OIDC Core Endpoints
OIDC (OpenID Connect) is an authentication layer built on top of OAuth 2.0. The following endpoints are essential.
| Endpoint | Path | Purpose |
|---|---|---|
| Discovery | /.well-known/openid-configuration | Provides all endpoint information |
| Authorization | /authorize | User authentication and consent |
| Token | /token | Token issuance/renewal |
| UserInfo | /userinfo | User information retrieval |
| JWKS | /jwks (or /certs) | Public keys for JWT verification |
| Introspection | /introspect | Token validation |
| Revocation | /revoke | Token invalidation |
1.4 JWT Structure
Header.Payload.Signature
Header: {"alg": "RS256", "typ": "JWT", "kid": "key-id-1"}
Payload: {"sub": "user123", "iss": "https://idp.example.com",
"aud": "my-client", "exp": 1710000000, "iat": 1709996400}
Signature: RS256(base64(header) + "." + base64(payload), privateKey)
2. Why SSO Mocking Is Necessary
2.1 Development Environment Issues
| Problem | Description |
|---|---|
| External dependency | Relying on a real IdP server halts development during network failures |
| CI/CD pipeline | Cannot log into a real IdP in automated tests |
| Rate limits | IdP API call limits prevent bulk testing |
| Cost | Commercial IdPs (Okta, Auth0, etc.) charge per API call |
| Token expiry control | Cannot freely set token expiration time in real IdP |
| Edge cases | Difficult to simulate token errors, expiry, invalid signatures |
| Multi-IdP | Hard to test with multiple IdPs simultaneously |
2.2 Mocking Strategy Overview
┌─────────────────────────────────────────────────────┐
│ SSO Mocking Strategies │
├─────────────────────────────────────────────────────┤
│ │
│ Lightweight Mock │ Heavy Mock │ Code │
│ ──────────────── │ ────────── │ ─────── │
│ mock-oauth2-server │ Keycloak │ @WithMock│
│ WireMock OIDC │ Testcontainers │ mockMvc │
│ oauth2-mock-server │ │ jose JWT│
│ │ │ │
│ Quick start │ Realistic testing │ Unit │
│ CI/CD optimal │ Integration test │ Fastest │
└─────────────────────────────────────────────────────┘
3. Method 1: mock-oauth2-server (NAV)
mock-oauth2-server is an open-source OAuth2/OIDC Mock server developed by NAV (Norwegian Labour and Welfare Administration). Written in Kotlin using OkHttp MockWebServer.
3.1 Key Features
- Automatic OIDC Discovery endpoint
- Simulate multiple Identity Providers on a single server
- Custom token claim configuration
- Docker image available
- JUnit 5 extension support
3.2 Running with Docker Compose
version: '3.8'
services:
mock-oauth2-server:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- "8080:8080"
environment:
- SERVER_PORT=8080
- JSON_CONFIG={
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "default",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "scope",
"match": "openid profile",
"claims": {
"sub": "testuser",
"name": "Test User",
"email": "test@example.com",
"groups": ["admin", "users"]
}
}
]
}
]
}
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8080/default/.well-known/openid-configuration"]
interval: 5s
timeout: 3s
retries: 10
my-application:
build: .
environment:
- OIDC_ISSUER_URL=http://mock-oauth2-server:8080/default
- OIDC_CLIENT_ID=my-client
- OIDC_CLIENT_SECRET=my-secret
depends_on:
mock-oauth2-server:
condition: service_healthy
3.3 Verifying Discovery Endpoint
curl http://localhost:8080/default/.well-known/openid-configuration | jq .
Response example:
{
"issuer": "http://localhost:8080/default",
"authorization_endpoint": "http://localhost:8080/default/authorize",
"token_endpoint": "http://localhost:8080/default/token",
"userinfo_endpoint": "http://localhost:8080/default/userinfo",
"jwks_uri": "http://localhost:8080/default/jwks",
"end_session_endpoint": "http://localhost:8080/default/endsession",
"response_types_supported": ["code", "id_token", "token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
3.4 Token Issuance Test
# Client Credentials Grant
curl -X POST http://localhost:8080/default/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=my-client" \
-d "client_secret=my-secret" \
-d "scope=openid profile"
3.5 JUnit 5 Integration
import no.nav.security.mock.oauth2.MockOAuth2Server
class MyServiceTest {
companion object {
val mockOAuth2Server = MockOAuth2Server()
@BeforeAll
@JvmStatic
fun setup() {
mockOAuth2Server.start(port = 8080)
}
@AfterAll
@JvmStatic
fun teardown() {
mockOAuth2Server.shutdown()
}
}
@Test
fun `should validate token`() {
val token = mockOAuth2Server.issueToken(
issuerId = "default",
subject = "testuser",
audience = "my-client",
claims = mapOf(
"name" to "Test User",
"email" to "test@example.com",
"roles" to listOf("admin")
)
)
val response = myService.callApi(token.serialize())
assertEquals(200, response.statusCode)
}
}
3.6 Simulating Multiple IdPs
# IdP 1: Internal employees
curl http://localhost:8080/internal/.well-known/openid-configuration
# IdP 2: External partners
curl http://localhost:8080/partner/.well-known/openid-configuration
# IdP 3: Customers
curl http://localhost:8080/customer/.well-known/openid-configuration
An independent OIDC Provider is auto-generated for each issuerId.
4. Method 2: OIDC Provider Simulation with WireMock
Using WireMock allows fine-grained control over each OIDC endpoint.
4.1 RSA Key Pair Generation
# Generate RSA private key
openssl genrsa -out private_key.pem 2048
# Extract public key
openssl rsa -in private_key.pem -pubout -out public_key.pem
# Convert to JWK format (using Node.js)
node -e "
const crypto = require('crypto');
const fs = require('fs');
const pem = fs.readFileSync('public_key.pem', 'utf8');
const key = crypto.createPublicKey(pem);
const jwk = key.export({ format: 'jwk' });
jwk.kid = 'test-key-1';
jwk.use = 'sig';
jwk.alg = 'RS256';
console.log(JSON.stringify({ keys: [jwk] }, null, 2));
" > jwks.json
4.2 OIDC Discovery Endpoint Stub
mappings/oidc-discovery.json:
{
"request": {
"method": "GET",
"urlPath": "/.well-known/openid-configuration"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"issuer": "http://localhost:8080",
"authorization_endpoint": "http://localhost:8080/authorize",
"token_endpoint": "http://localhost:8080/token",
"userinfo_endpoint": "http://localhost:8080/userinfo",
"jwks_uri": "http://localhost:8080/jwks",
"response_types_supported": ["code", "id_token", "token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
}
}
}
4.3 JWKS Endpoint Stub
{
"request": {
"method": "GET",
"urlPath": "/jwks"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "jwks.json"
}
}
4.4 Token Endpoint Stub
{
"request": {
"method": "POST",
"urlPath": "/token",
"bodyPatterns": [
{
"contains": "grant_type=client_credentials"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciJ9.SIGNATURE",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile"
}
}
}
4.5 UserInfo Endpoint Stub
{
"request": {
"method": "GET",
"urlPath": "/userinfo",
"headers": {
"Authorization": {
"matches": "Bearer .*"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"sub": "testuser",
"name": "Test User",
"email": "test@example.com",
"email_verified": true,
"groups": ["admin", "developers"]
}
}
}
4.6 Dynamic Token Issuance with Response Templating
{
"request": {
"method": "POST",
"urlPath": "/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"access_token\": \"mock-token-{{randomValue length=32 type='ALPHANUMERIC'}}\", \"token_type\": \"Bearer\", \"expires_in\": 3600, \"issued_at\": \"{{now}}\"}",
"transformers": ["response-template"]
}
}
5. Method 3: Keycloak Test Containers
5.1 Testcontainers Keycloak Module
Run a real Keycloak server as a Docker container for realistic integration testing.
Gradle dependencies:
testImplementation 'com.github.dasniko:testcontainers-keycloak:3.3.1'
testImplementation 'org.testcontainers:junit-jupiter:1.19.7'
Test code:
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest
class KeycloakIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:24.0")
.withRealmImportFile("test-realm.json");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm/protocol/openid-connect/certs");
}
@Test
void shouldAuthenticateWithKeycloak() {
String token = getAccessToken(
keycloak.getAuthServerUrl(),
"test-realm", "test-client",
"testuser", "password"
);
given()
.header("Authorization", "Bearer " + token)
.when()
.get("/api/protected")
.then()
.statusCode(200);
}
private String getAccessToken(String authServerUrl, String realm,
String clientId, String username, String password) {
return RestAssured
.given()
.contentType("application/x-www-form-urlencoded")
.formParam("grant_type", "password")
.formParam("client_id", clientId)
.formParam("username", username)
.formParam("password", password)
.post(authServerUrl + "/realms/" + realm + "/protocol/openid-connect/token")
.then()
.extract()
.path("access_token");
}
}
5.2 Realm Import File
test-realm.json:
{
"realm": "test-realm",
"enabled": true,
"sslRequired": "none",
"roles": {
"realm": [{ "name": "admin" }, { "name": "user" }]
},
"clients": [
{
"clientId": "test-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:*"],
"webOrigins": ["*"]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "test@example.com",
"firstName": "Test",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
],
"realmRoles": ["admin", "user"]
}
]
}
6. Method 4: Spring Security Test
6.1 @WithMockUser
The simplest approach -- testing with a mock user without actual authentication flow.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void adminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "user", roles = {"USER"})
void userCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isForbidden());
}
}
6.2 JWT Mock (mockOidcLogin / mockJwt)
@WebMvcTest(ApiController.class)
class JwtSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldAccessWithMockJwt() throws Exception {
mockMvc.perform(get("/api/data")
.with(jwt()
.jwt(j -> j
.subject("testuser")
.claim("email", "test@example.com")
.claim("roles", List.of("ADMIN"))
)
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isOk());
}
@Test
void shouldAccessWithMockOidcLogin() throws Exception {
mockMvc.perform(get("/api/profile")
.with(oidcLogin()
.idToken(token -> token
.subject("testuser")
.claim("name", "Test User")
.claim("email", "test@example.com")
)
.userInfoToken(userInfo -> userInfo
.claim("groups", List.of("admin"))
)
))
.andExpect(status().isOk());
}
}
7. Method 5: JWT Signing with jose in Node.js
const jose = require('jose')
const express = require('express')
async function startMockOIDC(port = 9000) {
const app = express()
app.use(express.urlencoded({ extended: true }))
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const publicJwk = await jose.exportJWK(publicKey)
publicJwk.kid = 'test-key-1'
publicJwk.use = 'sig'
publicJwk.alg = 'RS256'
const issuer = `http://localhost:${port}`
// Discovery
app.get('/.well-known/openid-configuration', (req, res) => {
res.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ['code', 'id_token', 'token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
})
})
// JWKS
app.get('/jwks', (req, res) => {
res.json({ keys: [publicJwk] })
})
// Token endpoint
app.post('/token', async (req, res) => {
const token = await new jose.SignJWT({
sub: 'testuser',
email: 'test@example.com',
name: 'Test User',
scope: req.body.scope || 'openid profile',
})
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
.setIssuer(issuer)
.setAudience(req.body.client_id || 'my-client')
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey)
res.json({
access_token: token,
id_token: token,
token_type: 'Bearer',
expires_in: 3600,
})
})
// UserInfo
app.get('/userinfo', (req, res) => {
res.json({
sub: 'testuser',
name: 'Test User',
email: 'test@example.com',
email_verified: true,
})
})
app.listen(port, () => console.log(`Mock OIDC on ${issuer}`))
}
startMockOIDC()
8. Method 6: Python authlib Mock
import pytest
from authlib.jose import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
def create_mock_token(claims=None):
default_claims = {
"sub": "testuser",
"iss": "http://localhost:9000",
"aud": "my-client",
"exp": 9999999999,
"iat": 1709996400,
"name": "Test User",
"email": "test@example.com",
}
if claims:
default_claims.update(claims)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, default_claims, private_key)
return token.decode("utf-8")
class TestOAuthProtectedEndpoint:
def test_authenticated_request(self, client):
token = create_mock_token({"roles": ["admin"]})
response = client.get(
"/api/data",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
def test_expired_token(self, client):
token = create_mock_token({"exp": 1000000000})
response = client.get(
"/api/data",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 401
9. Practical Tips
9.1 Token Expiry Testing
val token = mockOAuth2Server.issueToken(
issuerId = "default",
subject = "testuser",
expiry = 1 // expires in 1 second
)
Thread.sleep(2000) // wait 2 seconds
val response = client.get("/api/data") {
header("Authorization", "Bearer ${token.serialize()}")
}
assertEquals(401, response.status.value)
9.2 Refresh Token Scenario
{
"request": {
"method": "POST",
"urlPath": "/token",
"bodyPatterns": [{ "contains": "grant_type=refresh_token" }]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": {
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
9.3 Invalid Token Scenario
{
"request": {
"method": "GET",
"urlPath": "/api/protected",
"headers": {
"Authorization": {
"equalTo": "Bearer invalid-token"
}
}
},
"response": {
"status": 401,
"headers": {
"Content-Type": "application/json",
"WWW-Authenticate": "Bearer error=\"invalid_token\""
},
"jsonBody": {
"error": "invalid_token",
"error_description": "The access token is invalid or has expired"
}
}
}
10. CI/CD Integration Example
10.1 GitHub Actions + mock-oauth2-server
name: Integration Tests with SSO Mock
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mock-oauth2:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- 8080:8080
env:
SERVER_PORT: '8080'
options: >-
--health-cmd "wget --spider http://localhost:8080/default/.well-known/openid-configuration"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Run Tests
run: ./gradlew test
env:
OIDC_ISSUER_URL: http://localhost:8080/default
OIDC_CLIENT_ID: test-client
OIDC_JWKS_URI: http://localhost:8080/default/jwks
11. Tool Selection Guide
| Situation | Recommended Tool | Reason |
|---|---|---|
| Quick OIDC Mock needed | mock-oauth2-server | One Docker command, auto Discovery |
| Fine-grained OIDC endpoint control | WireMock | Custom response per endpoint |
| Realistic Keycloak integration test | Testcontainers Keycloak | Real Keycloak behavior |
| Spring Boot unit testing | @WithMockUser / mockJwt | Fastest, no external deps |
| Node.js project | jose + Express Mock | Lightweight, customizable |
| Python project | authlib Mock | Python ecosystem integration |
| Multi-IdP testing | mock-oauth2-server | Multiple issuers simultaneously |
12. Conclusion
SSO Mocking is an essential testing strategy in modern microservices development. Key takeaways:
- mock-oauth2-server is the simplest way to set up an OIDC Mock environment
- WireMock is suitable when fine-grained endpoint control is needed
- Keycloak Testcontainers provides the most realistic integration testing environment
- Spring Security Test is the fastest and simplest for unit testing
- Include SSO Mock in CI/CD pipelines for automated authentication testing
- Always test edge cases: token expiry, refresh, invalid signatures