Skip to content

Split View: [DevOps] SSO 서버 Mocking 가이드: OAuth2/OIDC 테스트 환경 구축

|

[DevOps] SSO 서버 Mocking 가이드: OAuth2/OIDC 테스트 환경 구축


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 LimitIdP의 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-serverDocker 한 줄로 실행, 자동 Discovery
OIDC 엔드포인트 세밀 제어WireMock각 엔드포인트별 커스텀 응답
현실적인 Keycloak 통합 테스트Testcontainers Keycloak실제 Keycloak 동작
Spring Boot 단위 테스트@WithMockUser / mockJwt가장 빠름, 외부 의존성 없음
Node.js 프로젝트jose + Express Mock경량, 커스터마이징 자유
Python 프로젝트authlib MockPython 생태계 통합
멀티 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

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.

EndpointPathPurpose
Discovery/.well-known/openid-configurationProvides all endpoint information
Authorization/authorizeUser authentication and consent
Token/tokenToken issuance/renewal
UserInfo/userinfoUser information retrieval
JWKS/jwks (or /certs)Public keys for JWT verification
Introspection/introspectToken validation
Revocation/revokeToken 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

ProblemDescription
External dependencyRelying on a real IdP server halts development during network failures
CI/CD pipelineCannot log into a real IdP in automated tests
Rate limitsIdP API call limits prevent bulk testing
CostCommercial IdPs (Okta, Auth0, etc.) charge per API call
Token expiry controlCannot freely set token expiration time in real IdP
Edge casesDifficult to simulate token errors, expiry, invalid signatures
Multi-IdPHard 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

SituationRecommended ToolReason
Quick OIDC Mock neededmock-oauth2-serverOne Docker command, auto Discovery
Fine-grained OIDC endpoint controlWireMockCustom response per endpoint
Realistic Keycloak integration testTestcontainers KeycloakReal Keycloak behavior
Spring Boot unit testing@WithMockUser / mockJwtFastest, no external deps
Node.js projectjose + Express MockLightweight, customizable
Python projectauthlib MockPython ecosystem integration
Multi-IdP testingmock-oauth2-serverMultiple 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