Skip to content
Published on

[DevOps] SSO Server Mocking Guide: Building OAuth2/OIDC Test Environments

Authors

1. SSO/OAuth2/OIDC Fundamentals Review

1.1 SSO (Single Sign-On)

SSO is an authentication mechanism that allows access to multiple services with a single authentication. Once a user logs in at the IdP (Identity Provider), they can access all connected SPs (Service Providers) without additional authentication.

┌──────┐     1. Login      ┌──────────┐
│ User │ ───────────────> │   IdP    │
└──┬───┘                  │(Keycloak)│
   │                      └────┬─────┘
   │  2. Token issued           │
   │ <──────────────────────────┘
   │  3. Access with token
   ├──────────────────> [Service A]
   ├──────────────────> [Service B]
   └──────────────────> [Service C]

1.2 OAuth 2.0 Authorization Code Flow

The most common OAuth 2.0 flow is the Authorization Code Flow.

┌──────┐                    ┌──────────┐                    ┌──────────┐
│Client│                    │  AuthZ   │                    │ Resource │
│(App) │                    │  Server  │                    │  Server  │
└──┬───┘                    └────┬─────┘                    └────┬─────┘
   │  1. /authorize              │                               │
   │────────────────────────────>│                               │
   │                             │  2. User authentication       │
   │                             │  (login page)                 │
   │  3. Authorization Code      │                               │
   │<────────────────────────────│                               │
   │                             │                               │
   │  4. POST /token             │                               │
   │    (code + client_secret)   │                               │
   │────────────────────────────>│                               │
   │                             │                               │
   │  5. Access Token + ID Token │                               │
   │<────────────────────────────│                               │
   │                             │                               │
   │  6. API call (Bearer Token) │                               │
   │─────────────────────────────┼──────────────────────────────>│
   │                             │                               │
   │  7. Resource response       │                               │
   │<────────────────────────────┼───────────────────────────────│

1.3 OIDC Core Endpoints

OIDC (OpenID Connect) is an authentication layer built on top of OAuth 2.0. The following endpoints are essential.

EndpointPathPurpose
Discovery/.well-known/openid-configurationProvides all endpoint information
Authorization/authorizeUser authentication and consent
Token/tokenToken issuance/renewal
UserInfo/userinfoUser information retrieval
JWKS/jwks (or /certs)Public keys for JWT verification
Introspection/introspectToken validation
Revocation/revokeToken invalidation

1.4 JWT Structure

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. Why SSO Mocking Is Necessary

2.1 Development Environment Issues

ProblemDescription
External dependencyRelying on a real IdP server halts development during network failures
CI/CD pipelineCannot log into a real IdP in automated tests
Rate limitsIdP API call limits prevent bulk testing
CostCommercial IdPs (Okta, Auth0, etc.) charge per API call
Token expiry controlCannot freely set token expiration time in real IdP
Edge casesDifficult to simulate token errors, expiry, invalid signatures
Multi-IdPHard to test with multiple IdPs simultaneously

2.2 Mocking Strategy Overview

┌─────────────────────────────────────────────────────┐
│              SSO Mocking Strategies                  │
├─────────────────────────────────────────────────────┤
│                                                     │
│  Lightweight Mock   │  Heavy Mock        │  Code    │
│  ────────────────   │  ──────────        │  ─────── │
│  mock-oauth2-server │  Keycloak          │  @WithMock│
│  WireMock OIDC      │  Testcontainers    │  mockMvc │
│  oauth2-mock-server │                    │  jose JWT│
│                     │                    │          │
│  Quick start        │  Realistic testing │  Unit    │
│  CI/CD optimal      │  Integration test  │  Fastest │
└─────────────────────────────────────────────────────┘

3. Method 1: mock-oauth2-server (NAV)

mock-oauth2-server is an open-source OAuth2/OIDC Mock server developed by NAV (Norwegian Labour and Welfare Administration). Written in Kotlin using OkHttp MockWebServer.

3.1 Key Features

  • Automatic OIDC Discovery endpoint
  • Simulate multiple Identity Providers on a single server
  • Custom token claim configuration
  • Docker image available
  • JUnit 5 extension support

3.2 Running with 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 Verifying Discovery Endpoint

curl http://localhost:8080/default/.well-known/openid-configuration | jq .

Response example:

{
  "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 Token Issuance Test

# 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.5 JUnit 5 Integration

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.6 Simulating Multiple IdPs

# IdP 1: Internal employees
curl http://localhost:8080/internal/.well-known/openid-configuration

# IdP 2: External partners
curl http://localhost:8080/partner/.well-known/openid-configuration

# IdP 3: Customers
curl http://localhost:8080/customer/.well-known/openid-configuration

An independent OIDC Provider is auto-generated for each issuerId.


4. Method 2: OIDC Provider Simulation with WireMock

Using WireMock allows fine-grained control over each OIDC endpoint.

4.1 RSA Key Pair Generation

# Generate RSA private key
openssl genrsa -out private_key.pem 2048

# Extract public key
openssl rsa -in private_key.pem -pubout -out public_key.pem

# Convert to JWK format (using 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 Endpoint 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"]
    }
  }
}

4.3 JWKS Endpoint Stub

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

4.4 Token Endpoint 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 Endpoint 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"]
    }
  }
}

4.6 Dynamic Token Issuance with Response Templating

{
  "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. Method 3: Keycloak Test Containers

5.1 Testcontainers Keycloak Module

Run a real Keycloak server as a Docker container for realistic integration testing.

Gradle dependencies:

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

Test code:

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() {
        String token = getAccessToken(
            keycloak.getAuthServerUrl(),
            "test-realm", "test-client",
            "testuser", "password"
        );

        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 File

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. Method 4: Spring Security Test

6.1 @WithMockUser

The simplest approach -- testing with a mock user without actual authentication flow.

@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. Method 5: JWT Signing with jose in Node.js

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

  // 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. Method 6: Python authlib Mock

import pytest
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. Practical Tips

9.1 Token Expiry Testing

val token = mockOAuth2Server.issueToken(
    issuerId = "default",
    subject = "testuser",
    expiry = 1  // expires in 1 second
)

Thread.sleep(2000)  // wait 2 seconds

val response = client.get("/api/data") {
    header("Authorization", "Bearer ${token.serialize()}")
}
assertEquals(401, response.status.value)

9.2 Refresh Token Scenario

{
  "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 Invalid Token Scenario

{
  "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 Integration Example

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. Tool Selection Guide

SituationRecommended ToolReason
Quick OIDC Mock neededmock-oauth2-serverOne Docker command, auto Discovery
Fine-grained OIDC endpoint controlWireMockCustom response per endpoint
Realistic Keycloak integration testTestcontainers KeycloakReal Keycloak behavior
Spring Boot unit testing@WithMockUser / mockJwtFastest, no external deps
Node.js projectjose + Express MockLightweight, customizable
Python projectauthlib MockPython ecosystem integration
Multi-IdP testingmock-oauth2-serverMultiple issuers simultaneously

12. Conclusion

SSO Mocking is an essential testing strategy in modern microservices development. Key takeaways:

  • mock-oauth2-server is the simplest way to set up an OIDC Mock environment
  • WireMock is suitable when fine-grained endpoint control is needed
  • Keycloak Testcontainers provides the most realistic integration testing environment
  • Spring Security Test is the fastest and simplest for unit testing
  • Include SSO Mock in CI/CD pipelines for automated authentication testing
  • Always test edge cases: token expiry, refresh, invalid signatures