Split View: [DevOps] SSO 서버 Mocking 가이드: OAuth2/OIDC 테스트 환경 구축
[DevOps] SSO 서버 Mocking 가이드: OAuth2/OIDC 테스트 환경 구축
- 1. SSO/OAuth2/OIDC 기본 개념 복습
- 2. 왜 SSO Mocking이 필요한가
- 3. 방법 1: mock-oauth2-server (NAV)
- 4. 방법 2: WireMock으로 OIDC Provider 시뮬레이션
- 5. 방법 3: Keycloak 테스트 컨테이너
- 6. 방법 4: Spring Security Test
- 7. 방법 5: Node.js에서 jose 라이브러리로 JWT 서명
- 8. 방법 6: Python에서 authlib Mock
- 9. 실전 팁
- 10. CI/CD 통합 예제
- 11. 도구 선택 가이드
- 12. 결론
1. SSO/OAuth2/OIDC 기본 개념 복습
1.1 SSO (Single Sign-On)
SSO는 한 번의 인증으로 여러 서비스에 접근할 수 있게 하는 인증 메커니즘이다. 사용자는 IdP(Identity Provider)에서 한 번 로그인하면, 연결된 모든 SP(Service Provider)에 별도 인증 없이 접근할 수 있다.
┌──────┐ 1. 로그인 ┌──────────┐
│ 사용자 │ ──────────────> │ IdP │
└──┬───┘ │(Keycloak)│
│ └────┬─────┘
│ 2. 토큰 발급 │
│ <─────────────────────────┘
│
│ 3. 토큰으로 접근
├──────────────────> [서비스 A]
├──────────────────> [서비스 B]
└──────────────────> [서비스 C]
1.2 OAuth 2.0 Authorization Code Flow
가장 일반적인 OAuth 2.0 플로우는 Authorization Code Flow다.
┌──────┐ ┌──────────┐ ┌──────────┐
│Client│ │ AuthZ │ │ Resource │
│(App) │ │ Server │ │ Server │
└──┬───┘ └────┬─────┘ └────┬─────┘
│ 1. /authorize │ │
│────────────────────────────>│ │
│ │ 2. 사용자 인증 │
│ │ (로그인 페이지) │
│ 3. Authorization Code │ │
│<────────────────────────────│ │
│ │ │
│ 4. POST /token │ │
│ (code + client_secret) │ │
│────────────────────────────>│ │
│ │ │
│ 5. Access Token + ID Token │ │
│<────────────────────────────│ │
│ │ │
│ 6. API 호출 (Bearer Token) │ │
│─────────────────────────────┼──────────────────────────────>│
│ │ │
│ 7. 리소스 응답 │ │
│<────────────────────────────┼───────────────────────────────│
1.3 OIDC (OpenID Connect) 핵심 엔드포인트
OIDC는 OAuth 2.0 위에 구축된 인증 레이어다. 다음 엔드포인트가 핵심이다.
| 엔드포인트 | 경로 | 용도 |
|---|---|---|
| Discovery | /.well-known/openid-configuration | 모든 엔드포인트 정보 제공 |
| Authorization | /authorize | 사용자 인증 및 동의 |
| Token | /token | 토큰 발급/갱신 |
| UserInfo | /userinfo | 사용자 정보 조회 |
| JWKS | /jwks (또는 /certs) | JWT 검증용 공개키 제공 |
| Introspection | /introspect | 토큰 유효성 검사 |
| Revocation | /revoke | 토큰 무효화 |
1.4 JWT (JSON Web Token) 구조
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. 왜 SSO Mocking이 필요한가
2.1 개발 환경의 문제점
| 문제 | 설명 |
|---|---|
| 외부 의존성 | 실제 IdP 서버에 의존하면 네트워크 장애 시 개발 중단 |
| CI/CD 파이프라인 | 자동화 테스트에서 실제 IdP에 로그인할 수 없음 |
| Rate Limit | IdP의 API 호출 제한으로 대량 테스트 불가 |
| 비용 | 상용 IdP(Okta, Auth0 등)는 API 호출당 과금 |
| 토큰 만료 제어 | 실제 IdP에서 토큰 만료 시간을 자유롭게 설정 불가 |
| 엣지 케이스 | 토큰 오류, 만료, 잘못된 서명 등을 시뮬레이션하기 어려움 |
| 멀티 IdP | 여러 IdP를 동시에 테스트하기 어려움 |
2.2 Mocking 전략 개요
┌─────────────────────────────────────────────────────┐
│ SSO Mocking 전략 │
├─────────────────────────────────────────────────────┤
│ │
│ 경량 Mock │ 중량 Mock │ 코드 레벨 │
│ ────────── │ ────────── │ ──────── │
│ mock-oauth2-server │ Keycloak │ @WithMock │
│ WireMock OIDC │ Testcontainers │ mockMvc │
│ oauth2-mock-server │ │ jose JWT │
│ │ │ │
│ 빠른 시작 │ 현실적인 테스트 │ 단위 테스트 │
│ CI/CD 최적 │ 통합 테스트 │ 가장 빠름 │
└─────────────────────────────────────────────────────┘
3. 방법 1: mock-oauth2-server (NAV)
mock-oauth2-server는 노르웨이 NAV(Norwegian Labour and Welfare Administration)에서 개발한 오픈소스 OAuth2/OIDC Mock 서버다. Kotlin으로 작성되었으며 OkHttp MockWebServer 기반이다.
3.1 주요 특징
- 자동 OIDC Discovery 엔드포인트 제공
- 여러 Identity Provider를 하나의 서버에서 시뮬레이션
- 커스텀 토큰 클레임 설정
- Docker 이미지 제공
- JUnit 5 확장 지원
3.2 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 Discovery 엔드포인트 확인
# OIDC Discovery
curl http://localhost:8080/default/.well-known/openid-configuration | jq .
응답 예시:
{
"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 토큰 발급 테스트
# 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"
# Authorization Code Grant (2단계)
# 1단계: Authorization Code 획득
curl -v "http://localhost:8080/default/authorize?\
response_type=code&\
client_id=my-client&\
redirect_uri=http://localhost:3000/callback&\
scope=openid+profile&\
state=random-state"
# 2단계: Code를 Token으로 교환
curl -X POST http://localhost:8080/default/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE_HERE" \
-d "client_id=my-client" \
-d "client_secret=my-secret" \
-d "redirect_uri=http://localhost:3000/callback"
3.5 JUnit 5 통합
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")
)
)
// 생성된 토큰으로 API 호출 테스트
val response = myService.callApi(token.serialize())
assertEquals(200, response.statusCode)
}
}
3.6 여러 IdP 시뮬레이션
# IdP 1: 내부 직원용
curl http://localhost:8080/internal/.well-known/openid-configuration
# IdP 2: 외부 파트너용
curl http://localhost:8080/partner/.well-known/openid-configuration
# IdP 3: 고객용
curl http://localhost:8080/customer/.well-known/openid-configuration
각 issuerId별로 독립적인 OIDC Provider가 자동 생성된다.
4. 방법 2: WireMock으로 OIDC Provider 시뮬레이션
WireMock을 사용하면 OIDC의 각 엔드포인트를 세밀하게 제어할 수 있다.
4.1 RSA 키 페어 생성
# RSA 개인키 생성
openssl genrsa -out private_key.pem 2048
# 공개키 추출
openssl rsa -in private_key.pem -pubout -out public_key.pem
# JWK 형식으로 변환 (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 엔드포인트 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"],
"claims_supported": ["sub", "name", "email", "iss", "aud", "exp", "iat"]
}
}
}
4.3 JWKS 엔드포인트 Stub
mappings/jwks.json:
{
"request": {
"method": "GET",
"urlPath": "/jwks"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "jwks.json"
}
}
4.4 Token 엔드포인트 Stub
mappings/token.json:
{
"request": {
"method": "POST",
"urlPath": "/token",
"bodyPatterns": [
{
"contains": "grant_type=client_credentials"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2V5LTEifQ.eyJzdWIiOiJ0ZXN0dXNlciIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImF1ZCI6Im15LWNsaWVudCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNzA5OTk2NDAwfQ.SIGNATURE",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile"
}
}
}
4.5 UserInfo 엔드포인트 Stub
mappings/userinfo.json:
{
"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 Response Templating으로 동적 토큰 발급
WireMock의 Response Templating을 활용하면 요청 파라미터에 따라 동적으로 토큰을 생성할 수 있다.
mappings/dynamic-token.json:
{
"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. 방법 3: Keycloak 테스트 컨테이너
5.1 Testcontainers Keycloak 모듈
실제 Keycloak 서버를 Docker 컨테이너로 실행하여 현실적인 통합 테스트를 수행할 수 있다.
Gradle 의존성:
testImplementation 'com.github.dasniko:testcontainers-keycloak:3.3.1'
testImplementation 'org.testcontainers:junit-jupiter:1.19.7'
테스트 코드:
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() {
// Keycloak에서 토큰 발급
String token = getAccessToken(
keycloak.getAuthServerUrl(),
"test-realm",
"test-client",
"testuser",
"password"
);
// 토큰으로 API 호출
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 파일
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. 방법 4: Spring Security Test
6.1 @WithMockUser
가장 간단한 방법으로, 실제 인증 플로우 없이 Mock 사용자로 테스트한다.
@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. 방법 5: Node.js에서 jose 라이브러리로 JWT 서명
7.1 jose를 사용한 JWT 생성
const jose = require('jose')
const http = require('http')
// RSA 키 페어 생성
async function createMockOIDCServer(port = 9000) {
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}`
// JWT 토큰 생성 함수
async function createToken(claims = {}) {
const token = await new jose.SignJWT({
sub: 'testuser',
email: 'test@example.com',
name: 'Test User',
...claims,
})
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
.setIssuer(issuer)
.setAudience('my-client')
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey)
return token
}
// HTTP 서버 생성
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json')
if (req.url === '/.well-known/openid-configuration') {
res.end(
JSON.stringify({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
})
)
} else if (req.url === '/jwks') {
res.end(JSON.stringify({ keys: [publicJwk] }))
} else if (req.url === '/token' && req.method === 'POST') {
const token = await createToken()
res.end(
JSON.stringify({
access_token: token,
token_type: 'Bearer',
expires_in: 3600,
})
)
} else if (req.url === '/userinfo') {
res.end(
JSON.stringify({
sub: 'testuser',
name: 'Test User',
email: 'test@example.com',
})
)
} else {
res.statusCode = 404
res.end(JSON.stringify({ error: 'not_found' }))
}
})
server.listen(port, () => {
console.log(`Mock OIDC Server running on ${issuer}`)
})
return { server, createToken }
}
// 사용법
createMockOIDCServer(9000).then(async ({ createToken }) => {
const token = await createToken({
roles: ['admin'],
department: 'engineering',
})
console.log('Generated token:', token)
})
7.2 Express 기반 Mock OIDC Server
const express = require('express')
const jose = require('jose')
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. 방법 6: Python에서 authlib Mock
# test_oauth.py
import pytest
import json
from unittest.mock import patch, MagicMock
from authlib.jose import jwt
from authlib.jose import JsonWebKey
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
# RSA 키 페어 생성
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
def create_mock_token(claims=None):
"""테스트용 JWT 토큰 생성"""
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
def test_missing_token(self, client):
response = client.get("/api/data")
assert response.status_code == 401
9. 실전 팁
9.1 토큰 만료 테스트
// mock-oauth2-server에서 매우 짧은 만료 시간 설정
val token = mockOAuth2Server.issueToken(
issuerId = "default",
subject = "testuser",
expiry = 1 // 1초 후 만료
)
Thread.sleep(2000) // 2초 대기
// 만료된 토큰으로 요청 -> 401 기대
val response = client.get("/api/data") {
header("Authorization", "Bearer ${token.serialize()}")
}
assertEquals(401, response.status.value)
9.2 리프레시 토큰 시나리오
{
"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 잘못된 토큰 시나리오
{
"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 통합 예제
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
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/
10.2 GitLab CI + Keycloak Testcontainer
integration-test:
stage: test
image: gradle:8-jdk21
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ''
TESTCONTAINERS_RYUK_DISABLED: 'true'
script:
- gradle integrationTest
artifacts:
reports:
junit: build/test-results/integrationTest/*.xml
11. 도구 선택 가이드
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| 빠른 OIDC Mock 필요 | mock-oauth2-server | Docker 한 줄로 실행, 자동 Discovery |
| OIDC 엔드포인트 세밀 제어 | WireMock | 각 엔드포인트별 커스텀 응답 |
| 현실적인 Keycloak 통합 테스트 | Testcontainers Keycloak | 실제 Keycloak 동작 |
| Spring Boot 단위 테스트 | @WithMockUser / mockJwt | 가장 빠름, 외부 의존성 없음 |
| Node.js 프로젝트 | jose + Express Mock | 경량, 커스터마이징 자유 |
| Python 프로젝트 | authlib Mock | Python 생태계 통합 |
| 멀티 IdP 테스트 | mock-oauth2-server | 여러 issuer 동시 지원 |
12. 결론
SSO Mocking은 현대 마이크로서비스 개발에서 필수적인 테스트 전략이다. 핵심 정리:
- mock-oauth2-server는 가장 간편하게 OIDC Mock 환경을 구축할 수 있는 도구
- WireMock은 엔드포인트별 세밀한 제어가 필요할 때 적합
- Keycloak Testcontainers는 실제에 가장 가까운 통합 테스트 환경 제공
- Spring Security Test는 단위 테스트에서 가장 빠르고 간편
- CI/CD 파이프라인에 SSO Mock을 포함시켜 자동화된 인증 테스트를 실행해야 한다
- 토큰 만료, 리프레시, 잘못된 서명 등 엣지 케이스를 반드시 테스트한다
[DevOps] SSO Server Mocking Guide: Building OAuth2/OIDC Test Environments
- 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