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
  • 可自定义令牌 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 Mockmock-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 流水线,跑自动化的认证测试
  • 令牌过期、刷新、签名无效等边界场景务必测试到位