- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 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. 実践的なTips
- 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コアエンドポイント
| エンドポイント | パス | 用途 |
|---|---|---|
| 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 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を含めて自動化された認証テストを実行すべき
- トークン有効期限切れ、リフレッシュ、不正な署名などのエッジケースを必ずテストする