Skip to content

Instantly share code, notes, and snippets.

@michaelsanford
Created August 12, 2025 19:17
Show Gist options
  • Save michaelsanford/e36e1b29bf50b931692f0b1074c25bab to your computer and use it in GitHub Desktop.
Save michaelsanford/e36e1b29bf50b931692f0b1074c25bab to your computer and use it in GitHub Desktop.
Keycloak SPI to AWS Secrets Manager with Rotation Detection
package com.example.keycloak.aws;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.models.KeycloakSession;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public class AwsSecretsManagerProvider implements SecretsProvider, AutoCloseable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final class Entry {
final JsonNode json;
final String versionId;
final Instant expiresAt;
Entry(JsonNode json, String versionId, Instant expiresAt) {
this.json = json;
this.versionId = versionId;
this.expiresAt = expiresAt;
}
}
private static volatile SecretsManagerClient client;
private final KeycloakSession session;
private final Region region;
private final Duration ttl;
private final Map<String, Entry> cache = new ConcurrentHashMap<>();
public AwsSecretsManagerProvider(KeycloakSession session, String region, Duration ttl) {
this.session = session;
this.region = Region.of(region);
this.ttl = ttl;
if (client == null) {
synchronized (AwsSecretsManagerProvider.class) {
if (client == null) {
client = SecretsManagerClient.builder().region(this.region).build();
}
}
}
}
@Override
public String getSecretField(String secretId, String fieldName) {
Entry e = cache.get(secretId);
if (e == null || Instant.now().isAfter(e.expiresAt)) {
e = refresh(secretId, e); // fetch AWSCURRENT; detect rotation by VersionId change
cache.put(secretId, e);
}
JsonNode node = e.json.get(fieldName);
if (node == null) throw new IllegalArgumentException("Field " + fieldName + " not found in secret " + secretId);
return node.asText();
}
private Entry refresh(String secretId, Entry previous) {
GetSecretValueResponse resp = client.getSecretValue(GetSecretValueRequest.builder()
.secretId(secretId)
.versionStage("AWSCURRENT") // pin to current to follow rotation
.build());
String newVersion = resp.versionId();
JsonNode json;
try {
json = MAPPER.readTree(resp.secretString());
} catch (IOException ex) {
throw new RuntimeException("Failed to parse secret JSON", ex);
}
// If VersionId changed, rotation detected; replace immediately. If same, just extend TTL.
boolean rotated = previous == null || !Objects.equals(previous.versionId, newVersion);
Instant expiry = Instant.now().plus(ttl);
return new Entry(json, newVersion, expiry);
}
@Override
public void close() {
// no-op; client is shared
}
}
package com.example.keycloak.aws;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import java.time.Duration;
public class AwsSecretsManagerProviderFactory implements EnvironmentDependentProviderFactory<SecretsProvider> {
@Override
public SecretsProvider create(KeycloakSession session) {
String region = env("AWS_REGION", "ca-central-1");
long ttlSeconds = Long.parseLong(env("SM_TTL_SECONDS", "300")); // 300 s default
return new AwsSecretsManagerProvider(session, region, Duration.ofSeconds(ttlSeconds));
}
private static String env(String k, String d) {
String v = System.getenv(k);
return (v == null || v.isBlank()) ? d : v;
}
@Override public void init(org.keycloak.Config.Scope config) {}
@Override public void postInit(KeycloakSessionFactory factory) {}
@Override public void close() {}
@Override public String getId() { return "aws-secrets-manager"; }
@Override public boolean isSupported(KeycloakSession session) { return true; }
}

Installation

Add to src/main/resources/META-INF/services/org.keycloak.provider.ProviderFactory

com.example.keycloak.aws.AwsSecretsManagerProviderFactory

Once deployed to /providers, you can use this SPI inside a custom authenticator, identity provider, or client configuration resolver. Example in a custom OIDC identity provider setup:

SecretsProvider sp = session.getProvider(SecretsProvider.class, "aws-secrets-manager");
String clientId = sp.getSecretField("entra-client-xyz", "client_id");
String clientSecret = sp.getSecretField("entra-client-xyz", "client_secret");

Behaviour

Reads AWSCURRENT version; caches for SM_TTL_SECONDS.

On expiry, re-fetches; if VersionId differs, rotation detected and applied.

Thread-safe; minimal latency; no restart required.

Configure IAM with secretsmanager:GetSecretValue on the target secret ARN only.

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.keycloak</groupId>
<artifactId>aws-sm-oidc-spi</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- Keycloak Server SPI -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- AWS SDK v2 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>secretsmanager</artifactId>
<version>2.29.18</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>auth</artifactId>
<version>2.29.18</version>
</dependency>
<!-- JSON parsing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
</dependencies>
</project>
package com.example.keycloak.aws;
import org.keycloak.provider.Provider;
public interface SecretsProvider extends Provider {
String getSecretField(String secretId, String fieldName);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment