Skip to content
Published on

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

Authors

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을 포함시켜 자동화된 인증 테스트를 실행해야 한다
  • 토큰 만료, 리프레시, 잘못된 서명 등 엣지 케이스를 반드시 테스트한다