Real tests and stable keys

Integration tests against real PostgreSQL with Testcontainers, and RSA signing keys that survive a restart.

  • Part 8
  • advanced
  • about 60 minutes

You will build

a Testcontainers-backed test suite covering registration, authorization boundaries and the full PKCE flow, plus PEM-loaded signing keys

You will understand

what mocked JWT tests cannot catch, why a regenerated key breaks every issued token, and how to keep private keys out of Git and images

  • one script, one key loader, three test classes

Two things can make an authorization server look healthy while hiding serious problems:

  1. tests that never touch the real database/migrations;
  2. signing keys that silently change after every restart.

We fix both here.

Test against real PostgreSQL

Create src/test/resources/application-test.yaml:

spring:
  application:
    name: spring-boot-oauth2-authorization-server
  datasource:
    url: jdbc:tc:postgresql:17:///oauth2_test
    username: test
    password: test
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
  flyway:
    enabled: true
    locations: classpath:db/migration

With the Testcontainers PostgreSQL JDBC driver, the test application starts against a disposable real PostgreSQL instance.

flowchart LR
    JUnit --> SpringBootTest
    SpringBootTest --> TC[Testcontainers PostgreSQL]
    TC --> Flyway
    Flyway --> Schema[(Real PostgreSQL schema)]
    SpringBootTest --> Schema

Integration-test the registration boundary

Example tests should verify:

valid registration → 201
password is encoded in DB
default USER role assigned
duplicate username → 409
invalid payload → 400

Use:

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")

Then query UserRepository after the API request and confirm:

passwordEncoder.matches(
    "Password123!",
    entity.getPassword()
)

Never assert that the stored hash equals a hardcoded BCrypt string; good password encoders intentionally use salts.

Test authorization boundaries

Management tests should cover at least:

no token       → 401
ROLE_USER      → 403
ROLE_ADMIN     → allowed

Spring Security’s MockMvc support lets us use a synthetic JWT for controller authorization tests:

.with(jwt().authorities(
    new SimpleGrantedAuthority("ROLE_ADMIN")
))

That is appropriate for testing the resource-server boundary. It does not replace full protocol tests.

Test the actual OAuth protocol

Create a dedicated integration test that:

  1. saves a test RegisteredClient;
  2. creates a PKCE verifier/challenge;
  3. performs /oauth2/authorize using an authenticated test user;
  4. extracts the authorization code from Location;
  5. posts to /oauth2/token;
  6. verifies access/refresh tokens;
  7. verifies wrong verifier rejection;
  8. verifies authorization-code reuse rejection;
  9. verifies refresh-token rotation.

This catches integration problems that mocked JWT tests cannot see.

Persistent signing keys

Our original startup-generated RSA key has a fatal production property:

restart application
      ↓
new keypair
      ↓
old JWT signatures no longer verify

We want:

private.pem + public.pem
      ↓
application restart
      ↓
same signing identity

Generate development PEM keys

Create scripts/generate-dev-keys.sh:

#!/usr/bin/env bash
set -euo pipefail

mkdir -p .local/keys

if [[ -e .local/keys/private.pem || -e .local/keys/public.pem ]]; then
  echo "Key files already exist; refusing to overwrite."
  exit 1
fi

openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:3072 \
  -out .local/keys/private.pem

openssl pkey \
  -in .local/keys/private.pem \
  -pubout \
  -out .local/keys/public.pem

chmod 600 .local/keys/private.pem
chmod 644 .local/keys/public.pem

Add:

.local/

to .gitignore.

Load PEM keys

Create configuration properties pointing to resource locations such as:

.local/keys/private.pem
.local/keys/public.pem

A PEM loader should:

  • strip PEM headers/footers;
  • Base64-decode the DER bytes;
  • load the private key using PKCS8EncodedKeySpec;
  • load the public key using X509EncodedKeySpec;
  • build a Nimbus RSAKey;
  • derive a stable kid, for example from SHA-256 of the encoded public key.

Conceptually:

flowchart LR
    Priv["PKCS#8 private.pem"] --> Loader[PEM Loader]
    Pub[X.509 public.pem] --> Loader
    Loader --> RSA[Nimbus RSAKey]
    RSA --> JWK[JWKSource]
    JWK --> Encoder[JWT Encoder]
    JWK --> JWKS["/oauth2/jwks"]

Separate production/dev key loading from tests

Production-like profiles:

@Bean
@Profile("!test")
JWKSource<SecurityContext> persistentJwkSource(...) {
    // load PEM files
}

Test profile:

@Bean
@Profile("test")
JWKSource<SecurityContext> testJwkSource() {
    // ephemeral test key
}

CI should not need real private signing files.

Verify key persistence

Start the server and capture:

curl -s http://localhost:9000/oauth2/jwks | jq -r '.keys[0].kid'

Restart the application and run it again.

The kid should be identical.

You can go further: obtain an access token, restart the application, and use the unexpired token. It should still verify because the signing key did not change.

Security note

PEM files on disk are a practical template-level mechanism, not the end state for every production system.

Higher-security deployments may load signing material from:

KMS
HSM
Vault/secret manager
managed key service

The crucial design principle is that key material is external to source control and application images, and rotation is intentional rather than accidental.

Run the suite

docker info > /dev/null && echo "Docker is running"
./mvnw clean test

The code

This part corresponds to these commits in the repository:

  • fb33fc5 test: add PostgreSQL integration test foundation
  • b949a60 test: cover OAuth2 authorization code and PKCE flow
  • cc595d2 feat: persist authorization server signing keys

Check out the last one to see the project exactly as this part leaves it:

git checkout cc595d2

References

Next: build the account-management and password-security controls expected around a real authorization server.