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コアエンドポイント

エンドポイントパス用途
Discovery/.well-known/openid-configuration全エンドポイント情報提供
Authorization/authorizeユーザー認証と同意
Token/tokenトークン発行/更新
UserInfo/userinfoユーザー情報照会
JWKS/jwks(または /certs)JWT検証用公開鍵提供
Introspection/introspectトークン有効性検査
Revocation/revokeトークン無効化

1.4 JWT構造

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にログインできない
レートリミットIdPのAPI呼び出し制限で大量テスト不可
コスト商用IdP(Okta、Auth0等)はAPI呼び出し毎に課金
トークン有効期限制御実際の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サーバーです。

3.1 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.2 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",
  "response_types_supported": ["code", "id_token", "token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

3.3 トークン発行テスト

# 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.4 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")
            )
        )

        val response = myService.callApi(token.serialize())
        assertEquals(200, response.statusCode)
    }
}

3.5 複数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シミュレーション

4.1 RSAキーペア生成

openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem

4.2 OIDC Discoveryエンドポイント Stub

{
  "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"]
    }
  }
}

4.3 JWKSエンドポイント Stub

{
  "request": {
    "method": "GET",
    "urlPath": "/jwks"
  },
  "response": {
    "status": 200,
    "headers": { "Content-Type": "application/json" },
    "bodyFileName": "jwks.json"
  }
}

4.4 Tokenエンドポイント 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エンドポイント 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"]
    }
  }
}

5. 方法3:Keycloakテストコンテナ

5.1 Testcontainers Keycloakモジュール

testImplementation 'com.github.dasniko:testcontainers-keycloak:3.3.1'
testImplementation 'org.testcontainers:junit-jupiter:1.19.7'
import dasniko.testcontainers.keycloak.KeycloakContainer;

@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");
    }

    @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);
    }
}

5.2 Realmインポートファイル

{
  "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",
      "credentials": [
        {
          "type": "password",
          "value": "password",
          "temporary": false
        }
      ],
      "realmRoles": ["admin", "user"]
    }
  ]
}

6. 方法4:Spring Security Test

6.1 @WithMockUser

@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

@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());
}

7. 方法5:Node.jsでjoseライブラリによるJWT署名

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}`

  app.get('/.well-known/openid-configuration', (req, res) => {
    res.json({
      issuer,
      token_endpoint: `${issuer}/token`,
      jwks_uri: `${issuer}/jwks`,
    })
  })

  app.get('/jwks', (req, res) => {
    res.json({ keys: [publicJwk] })
  })

  app.post('/token', async (req, res) => {
    const token = await new jose.SignJWT({
      sub: 'testuser',
      email: 'test@example.com',
    })
      .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,
      token_type: 'Bearer',
      expires_in: 3600,
    })
  })

  app.get('/userinfo', (req, res) => {
    res.json({
      sub: 'testuser',
      name: 'Test User',
      email: 'test@example.com',
    })
  })

  app.listen(port, () => console.log(`Mock OIDC on ${issuer}`))
}

startMockOIDC()

8. 方法6:Pythonでauthlib Mock

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. 実践的なTips

9.1 トークン有効期限テスト

val token = mockOAuth2Server.issueToken(
    issuerId = "default",
    subject = "testuser",
    expiry = 1  // 1秒後に有効期限切れ
)

Thread.sleep(2000)  // 2秒待機

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
    }
  }
}

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

11. ツール選択ガイド

状況推奨ツール理由
高速なOIDC Mockmock-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を含めて自動化された認証テストを実行すべき
  • トークン有効期限切れ、リフレッシュ、不正な署名などのエッジケースを必ずテストする