- 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. 实战技巧
- 10. CI/CD 集成示例
- 11. 工具选型指南
- 12. 结论
1. SSO/OAuth2/OIDC 基本概念回顾
1.1 SSO(Single Sign-On)
SSO 是让用户只需认证一次就能访问多个服务的认证机制。用户在 IdP(Identity Provider)登录一次之后,就能不再单独认证地访问所有已接入的 SP(Service Provider)。
┌──────┐ 1. 登录 ┌──────────┐
│ 用户 │ ──────────────> │ IdP │
└──┬───┘ │(Keycloak)│
│ └────┬─────┘
│ 2. 签发令牌 │
│ <─────────────────────────┘
│
│ 3. 用令牌访问
├──────────────────> [服务 A]
├──────────────────> [服务 B]
└──────────────────> [服务 C]
1.2 OAuth 2.0 Authorization Code Flow
最常见的 OAuth 2.0 流程是 Authorization Code Flow。
┌──────┐ ┌──────────┐ ┌──────────┐
│Client│ │ AuthZ │ │ Resource │
│(App) │ │ Server │ │ Server │
└──┬───┘ └────┬─────┘ └────┬─────┘
│ 1. /authorize │ │
│────────────────────────────>│ │
│ │ 2. 用户认证 │
│ │ (登录页面) │
│ 3. Authorization Code │ │
│<────────────────────────────│ │
│ │ │
│ 4. POST /token │ │
│ (code + client_secret) │ │
│────────────────────────────>│ │
│ │ │
│ 5. Access Token + ID Token │ │
│<────────────────────────────│ │
│ │ │
│ 6. API 调用 (Bearer Token) │ │
│─────────────────────────────┼──────────────────────────────>│
│ │ │
│ 7. 资源响应 │ │
│<────────────────────────────┼───────────────────────────────│
1.3 OIDC(OpenID Connect)核心端点
OIDC 是构建在 OAuth 2.0 之上的认证层。以下端点是它的核心。
| 端点 | 路径 | 用途 |
|---|---|---|
| Discovery | /.well-known/openid-configuration | 提供全部端点信息 |
| Authorization | /authorize | 用户认证与授权同意 |
| Token | /token | 令牌签发/刷新 |
| UserInfo | /userinfo | 查询用户信息 |
| JWKS | /jwks(或 /certs) | 提供 JWT 验证用公钥 |
| Introspection | /introspect | 令牌有效性检查 |
| Revocation | /revoke | 令牌失效 |
1.4 JWT(JSON Web Token)结构
Header.Payload.Signature
Header: {"alg": "RS256", "typ": "JWT", "kid": "key-id-1"}
Payload: {"sub": "user123", "iss": "https://idp.example.com",
"aud": "my-client", "exp": 1710000000, "iat": 1709996400}
Signature: RS256(base64(header) + "." + base64(payload), privateKey)
2. 为什么需要 SSO Mocking
2.1 开发环境的痛点
| 问题 | 说明 |
|---|---|
| 外部依赖 | 依赖真实 IdP 服务器时,网络故障会导致开发中断 |
| CI/CD 流水线 | 自动化测试无法登录真实 IdP |
| Rate Limit | IdP 的 API 调用限制让大批量测试无法进行 |
| 成本 | 商用 IdP(Okta、Auth0 等)按 API 调用次数计费 |
| 令牌过期控制 | 无法在真实 IdP 上自由设置令牌过期时间 |
| 边界场景 | 令牌错误、过期、签名无效等情况难以模拟 |
| 多 IdP | 难以同时测试多个 IdP |
2.2 Mocking 策略概览
┌─────────────────────────────────────────────────────┐
│ SSO Mocking 策略 │
├─────────────────────────────────────────────────────┤
│ │
│ 轻量 Mock │ 重量 Mock │ 代码级别 │
│ ────────── │ ────────── │ ──────── │
│ mock-oauth2-server │ Keycloak │ @WithMock │
│ WireMock OIDC │ Testcontainers │ mockMvc │
│ oauth2-mock-server │ │ jose JWT │
│ │ │ │
│ 启动最快 │ 贴近真实的测试 │ 单元测试 │
│ CI/CD 最优 │ 集成测试 │ 速度最快 │
└─────────────────────────────────────────────────────┘
3. 方法 1:mock-oauth2-server(NAV)
mock-oauth2-server 是挪威 NAV(Norwegian Labour and Welfare Administration)开发的开源 OAuth2/OIDC Mock 服务器。它用 Kotlin 编写,基于 OkHttp MockWebServer。
3.1 主要特性
- 自动提供 OIDC Discovery 端点
- 在一台服务器上模拟多个 Identity Provider
- 可自定义令牌 claim
- 提供 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 (两步)
# 第 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`() {
// 用自定义 claim 生成令牌
val token = mockOAuth2Server.issueToken(
issuerId = "default",
subject = "testuser",
audience = "my-client",
claims = mapOf(
"name" to "Test User",
"email" to "test@example.com",
"roles" to listOf("admin")
)
)
// 用生成的令牌测试 API 调用
val response = myService.callApi(token.serialize())
assertEquals(200, response.statusCode)
}
}
3.6 模拟多个 IdP
# IdP 1: 内部员工用
curl http://localhost:8080/internal/.well-known/openid-configuration
# IdP 2: 外部合作伙伴用
curl http://localhost:8080/partner/.well-known/openid-configuration
# IdP 3: 客户用
curl http://localhost:8080/customer/.well-known/openid-configuration
每个 issuerId 都会自动生成一个独立的 OIDC Provider。
4. 方法 2:用 WireMock 模拟 OIDC Provider
使用 WireMock 可以对 OIDC 的每个端点做精细控制。
4.1 生成 RSA 密钥对
# 生成 RSA 私钥
openssl genrsa -out private_key.pem 2048
# 提取公钥
openssl rsa -in private_key.pem -pubout -out public_key.pem
# 转换为 JWK 格式 (使用 Node.js)
node -e "
const crypto = require('crypto');
const fs = require('fs');
const pem = fs.readFileSync('public_key.pem', 'utf8');
const key = crypto.createPublicKey(pem);
const jwk = key.export({ format: 'jwk' });
jwk.kid = 'test-key-1';
jwk.use = 'sig';
jwk.alg = 'RS256';
console.log(JSON.stringify({ keys: [jwk] }, null, 2));
" > jwks.json
4.2 OIDC Discovery 端点 Stub
mappings/oidc-discovery.json:
{
"request": {
"method": "GET",
"urlPath": "/.well-known/openid-configuration"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"issuer": "http://localhost:8080",
"authorization_endpoint": "http://localhost:8080/authorize",
"token_endpoint": "http://localhost:8080/token",
"userinfo_endpoint": "http://localhost:8080/userinfo",
"jwks_uri": "http://localhost:8080/jwks",
"response_types_supported": ["code", "id_token", "token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"claims_supported": ["sub", "name", "email", "iss", "aud", "exp", "iat"]
}
}
}
4.3 JWKS 端点 Stub
mappings/jwks.json:
{
"request": {
"method": "GET",
"urlPath": "/jwks"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "jwks.json"
}
}
4.4 Token 端点 Stub
mappings/token.json:
{
"request": {
"method": "POST",
"urlPath": "/token",
"bodyPatterns": [
{
"contains": "grant_type=client_credentials"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2V5LTEifQ.eyJzdWIiOiJ0ZXN0dXNlciIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImF1ZCI6Im15LWNsaWVudCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNzA5OTk2NDAwfQ.SIGNATURE",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile"
}
}
}
4.5 UserInfo 端点 Stub
mappings/userinfo.json:
{
"request": {
"method": "GET",
"urlPath": "/userinfo",
"headers": {
"Authorization": {
"matches": "Bearer .*"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"sub": "testuser",
"name": "Test User",
"email": "test@example.com",
"email_verified": true,
"groups": ["admin", "developers"]
}
}
}
4.6 用 Response Templating 动态签发令牌
借助 WireMock 的 Response Templating,可以按请求参数动态生成令牌。
mappings/dynamic-token.json:
{
"request": {
"method": "POST",
"urlPath": "/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"access_token\": \"mock-token-{{randomValue length=32 type='ALPHANUMERIC'}}\", \"token_type\": \"Bearer\", \"expires_in\": 3600, \"issued_at\": \"{{now}}\"}",
"transformers": ["response-template"]
}
}
5. 方法 3:Keycloak 测试容器
5.1 Testcontainers Keycloak 模块
把真实的 Keycloak 服务器跑在 Docker 容器里,就能做贴近生产的集成测试。
Gradle 依赖:
testImplementation 'com.github.dasniko:testcontainers-keycloak:3.3.1'
testImplementation 'org.testcontainers:junit-jupiter:1.19.7'
测试代码:
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest
class KeycloakIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:24.0")
.withRealmImportFile("test-realm.json");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm/protocol/openid-connect/certs");
}
@Test
void shouldAuthenticateWithKeycloak() {
// 从 Keycloak 签发令牌
String token = getAccessToken(
keycloak.getAuthServerUrl(),
"test-realm",
"test-client",
"testuser",
"password"
);
// 用令牌调用 API
given()
.header("Authorization", "Bearer " + token)
.when()
.get("/api/protected")
.then()
.statusCode(200);
}
private String getAccessToken(String authServerUrl, String realm,
String clientId, String username, String password) {
return RestAssured
.given()
.contentType("application/x-www-form-urlencoded")
.formParam("grant_type", "password")
.formParam("client_id", clientId)
.formParam("username", username)
.formParam("password", password)
.post(authServerUrl + "/realms/" + realm + "/protocol/openid-connect/token")
.then()
.extract()
.path("access_token");
}
}
5.2 Realm Import 文件
test-realm.json:
{
"realm": "test-realm",
"enabled": true,
"sslRequired": "none",
"roles": {
"realm": [{ "name": "admin" }, { "name": "user" }]
},
"clients": [
{
"clientId": "test-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:*"],
"webOrigins": ["*"]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "test@example.com",
"firstName": "Test",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
],
"realmRoles": ["admin", "user"]
}
]
}
6. 方法 4:Spring Security Test
6.1 @WithMockUser
最简单的方式,不走真实认证流程,直接用 Mock 用户做测试。
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void adminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "user", roles = {"USER"})
void userCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isForbidden());
}
}
6.2 JWT Mock(mockOidcLogin / mockJwt)
@WebMvcTest(ApiController.class)
class JwtSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldAccessWithMockJwt() throws Exception {
mockMvc.perform(get("/api/data")
.with(jwt()
.jwt(j -> j
.subject("testuser")
.claim("email", "test@example.com")
.claim("roles", List.of("ADMIN"))
)
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isOk());
}
@Test
void shouldAccessWithMockOidcLogin() throws Exception {
mockMvc.perform(get("/api/profile")
.with(oidcLogin()
.idToken(token -> token
.subject("testuser")
.claim("name", "Test User")
.claim("email", "test@example.com")
)
.userInfoToken(userInfo -> userInfo
.claim("groups", List.of("admin"))
)
))
.andExpect(status().isOk());
}
}
7. 方法 5:在 Node.js 中用 jose 库签名 JWT
7.1 用 jose 生成 JWT
const jose = require('jose')
const http = require('http')
// 生成 RSA 密钥对
async function createMockOIDCServer(port = 9000) {
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const publicJwk = await jose.exportJWK(publicKey)
publicJwk.kid = 'test-key-1'
publicJwk.use = 'sig'
publicJwk.alg = 'RS256'
const issuer = `http://localhost:${port}`
// JWT 令牌生成函数
async function createToken(claims = {}) {
const token = await new jose.SignJWT({
sub: 'testuser',
email: 'test@example.com',
name: 'Test User',
...claims,
})
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
.setIssuer(issuer)
.setAudience('my-client')
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey)
return token
}
// 创建 HTTP 服务器
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json')
if (req.url === '/.well-known/openid-configuration') {
res.end(
JSON.stringify({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
})
)
} else if (req.url === '/jwks') {
res.end(JSON.stringify({ keys: [publicJwk] }))
} else if (req.url === '/token' && req.method === 'POST') {
const token = await createToken()
res.end(
JSON.stringify({
access_token: token,
token_type: 'Bearer',
expires_in: 3600,
})
)
} else if (req.url === '/userinfo') {
res.end(
JSON.stringify({
sub: 'testuser',
name: 'Test User',
email: 'test@example.com',
})
)
} else {
res.statusCode = 404
res.end(JSON.stringify({ error: 'not_found' }))
}
})
server.listen(port, () => {
console.log(`Mock OIDC Server running on ${issuer}`)
})
return { server, createToken }
}
// 用法
createMockOIDCServer(9000).then(async ({ createToken }) => {
const token = await createToken({
roles: ['admin'],
department: 'engineering',
})
console.log('Generated token:', token)
})
7.2 基于 Express 的 Mock OIDC Server
const express = require('express')
const jose = require('jose')
async function startMockOIDC(port = 9000) {
const app = express()
app.use(express.urlencoded({ extended: true }))
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const publicJwk = await jose.exportJWK(publicKey)
publicJwk.kid = 'test-key-1'
publicJwk.use = 'sig'
publicJwk.alg = 'RS256'
const issuer = `http://localhost:${port}`
// Discovery
app.get('/.well-known/openid-configuration', (req, res) => {
res.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ['code', 'id_token', 'token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
})
})
// JWKS
app.get('/jwks', (req, res) => {
res.json({ keys: [publicJwk] })
})
// Token endpoint
app.post('/token', async (req, res) => {
const token = await new jose.SignJWT({
sub: 'testuser',
email: 'test@example.com',
name: 'Test User',
scope: req.body.scope || 'openid profile',
})
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
.setIssuer(issuer)
.setAudience(req.body.client_id || 'my-client')
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey)
res.json({
access_token: token,
id_token: token,
token_type: 'Bearer',
expires_in: 3600,
})
})
// UserInfo
app.get('/userinfo', (req, res) => {
res.json({
sub: 'testuser',
name: 'Test User',
email: 'test@example.com',
email_verified: true,
})
})
app.listen(port, () => console.log(`Mock OIDC on ${issuer}`))
}
startMockOIDC()
8. 方法 6:在 Python 中用 authlib 做 Mock
# test_oauth.py
import pytest
import json
from unittest.mock import patch, MagicMock
from authlib.jose import jwt
from authlib.jose import JsonWebKey
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
# 生成 RSA 密钥对
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
def create_mock_token(claims=None):
"""生成测试用的 JWT 令牌"""
default_claims = {
"sub": "testuser",
"iss": "http://localhost:9000",
"aud": "my-client",
"exp": 9999999999,
"iat": 1709996400,
"name": "Test User",
"email": "test@example.com",
}
if claims:
default_claims.update(claims)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, default_claims, private_key)
return token.decode("utf-8")
class TestOAuthProtectedEndpoint:
def test_authenticated_request(self, client):
token = create_mock_token({"roles": ["admin"]})
response = client.get(
"/api/data",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
def test_expired_token(self, client):
token = create_mock_token({"exp": 1000000000})
response = client.get(
"/api/data",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 401
def test_missing_token(self, client):
response = client.get("/api/data")
assert response.status_code == 401
9. 实战技巧
9.1 令牌过期测试
// 在 mock-oauth2-server 中设置极短的过期时间
val token = mockOAuth2Server.issueToken(
issuerId = "default",
subject = "testuser",
expiry = 1 // 1 秒后过期
)
Thread.sleep(2000) // 等待 2 秒
// 用过期令牌发请求 -> 期望 401
val response = client.get("/api/data") {
header("Authorization", "Bearer ${token.serialize()}")
}
assertEquals(401, response.status.value)
9.2 刷新令牌场景
{
"request": {
"method": "POST",
"urlPath": "/token",
"bodyPatterns": [{ "contains": "grant_type=refresh_token" }]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": {
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
9.3 无效令牌场景
{
"request": {
"method": "GET",
"urlPath": "/api/protected",
"headers": {
"Authorization": {
"equalTo": "Bearer invalid-token"
}
}
},
"response": {
"status": 401,
"headers": {
"Content-Type": "application/json",
"WWW-Authenticate": "Bearer error=\"invalid_token\""
},
"jsonBody": {
"error": "invalid_token",
"error_description": "The access token is invalid or has expired"
}
}
}
10. CI/CD 集成示例
10.1 GitHub Actions + mock-oauth2-server
name: Integration Tests with SSO Mock
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mock-oauth2:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- 8080:8080
env:
SERVER_PORT: '8080'
options: >-
--health-cmd "wget --spider http://localhost:8080/default/.well-known/openid-configuration"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Run Tests
run: ./gradlew test
env:
OIDC_ISSUER_URL: http://localhost:8080/default
OIDC_CLIENT_ID: test-client
OIDC_JWKS_URI: http://localhost:8080/default/jwks
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: build/reports/tests/
10.2 GitLab CI + Keycloak Testcontainer
integration-test:
stage: test
image: gradle:8-jdk21
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ''
TESTCONTAINERS_RYUK_DISABLED: 'true'
script:
- gradle integrationTest
artifacts:
reports:
junit: build/test-results/integrationTest/*.xml
11. 工具选型指南
| 场景 | 推荐工具 | 理由 |
|---|---|---|
| 需要快速搭个 OIDC Mock | mock-oauth2-server | 一行 Docker 跑起来,自动 Discovery |
| 需要精细控制 OIDC 端点 | WireMock | 每个端点都能自定义响应 |
| 贴近真实的 Keycloak 集成测试 | Testcontainers Keycloak | 真实 Keycloak 行为 |
| Spring Boot 单元测试 | @WithMockUser / mockJwt | 速度最快,无外部依赖 |
| Node.js 项目 | jose + Express Mock | 轻量,定制自由 |
| Python 项目 | authlib Mock | 与 Python 生态集成 |
| 多 IdP 测试 | mock-oauth2-server | 同时支持多个 issuer |
12. 结论
SSO Mocking 是现代微服务开发中不可或缺的测试策略。核心要点:
- mock-oauth2-server 是最省事地搭起 OIDC Mock 环境的工具
- WireMock 适合需要按端点精细控制的场景
- Keycloak Testcontainers 提供最接近真实环境的集成测试
- Spring Security Test 在单元测试里最快、最省事
- 应把 SSO Mock 纳入 CI/CD 流水线,跑自动化的认证测试
- 令牌过期、刷新、签名无效等边界场景务必测试到位