Real tests and stable keys
Integration tests against real PostgreSQL with Testcontainers, and RSA signing keys that survive a restart.
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:
- tests that never touch the real database/migrations;
- 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:
- saves a test
RegisteredClient; - creates a PKCE verifier/challenge;
- performs
/oauth2/authorizeusing an authenticated test user; - extracts the authorization code from
Location; - posts to
/oauth2/token; - verifies access/refresh tokens;
- verifies wrong verifier rejection;
- verifies authorization-code reuse rejection;
- 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:
fb33fc5test: add PostgreSQL integration test foundationb949a60test: cover OAuth2 authorization code and PKCE flowcc595d2feat: persist authorization server signing keys
Check out the last one to see the project exactly as this part leaves it:
git checkout cc595d2
References
- Testcontainers Java: https://java.testcontainers.org/
- Testcontainers PostgreSQL: https://java.testcontainers.org/modules/databases/postgres/
- JWK — RFC 7517: https://www.rfc-editor.org/rfc/rfc7517
- JWT — RFC 7519: https://www.rfc-editor.org/rfc/rfc7519
Next: build the account-management and password-security controls expected around a real authorization server.