Support for JWKS
MODIFY
keycloak-oauth-policy/pom.xml
keycloak-oauth-policy/pom.xml
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.httpcomponents</groupId>
+ <artifactId>httpclient</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.jboss.logging</groupId>
+ <artifactId>jboss-logging</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.keycloak</groupId>
+ <artifactId>keycloak-adapter-core</artifactId>
+ </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-common</artifactId>
<artifactId>mockito-all</artifactId>
<scope>test</scope>
</dependency>
+ <!-- mockserver -->
+ <dependency>
+ <groupId>org.mock-server</groupId>
+ <artifactId>mockserver-netty</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>

MODIFY
keycloak-oauth-policy/src/main/apiman/policyDefs/schemas/keycloak-oauth-policyDef.schema
keycloak-oauth-policy/src/main/apiman/policyDefs/schemas/keycloak-oauth-policyDef.schema
},
"requireTransportSecurity": {
"title": "Require Transport Security",
- "description": "Any request used without transport security will be rejected. OAuth2 requires transport security (e.g. TLS, SSL) to provide protection against replay attacks. It is strongly advised for this option to be switched on.",
+ "description": "Any request without transport security will be rejected. OAuth2 requires transport security (e.g. TLS, SSL) to provide protection against replay attacks. It is strongly recommended to enable this option.",
"type": "boolean",
"default": true
},
"blacklistUnsafeTokens": {
"title": "Blacklist Unsafe Tokens",
- "description": "Any tokens used without transport security will be blackedlisted in all gateways to mitigate associated security risks. Uses distributed data store to share blacklist.",
+ "description": "Any tokens used without transport security will be blacklisted in all gateways to mitigate associated security risks. Uses distributed data store to share blacklist.",
"type": "boolean",
"default": false
},
"stripTokens": {
"title": "Strip Tokens",
- "description": "Remove any Authorization header or token query parameter before forwarding traffic to the API.",
+ "description": "Remove any authorization header or token query parameter before forwarding traffic to the API.",
"type": "boolean",
"default": false
},
"realm": {
"title": "Realm",
- "description": "Realm name. If you are using Keycloak 1.2.0x or later this must be a full iss domain path (e.g. https://mykeycloak.local/auth/realms/apimanrealm); pre-1.2.0x simply use the realm name (e.g. apimanrealm).",
+ "description": "Realm name. Must be a full ISS domain path (e.g. https://mykeycloak.local/auth/realms/apimanrealm) is required.",
"type": "string"
},
"realmCertificateString": {
"title": "Keycloak Realm Certificate",
- "description": "To validate OAuth2 requests. Must be a PEM-encoded X.509 certificate.",
+ "description": "To validate OAuth2 requests. Must be a PEM-encoded X.509 certificate. If you leave this empty the policy will try to get the public-keys directly from your Keycloak.",
"type": "string",
"format": "textarea"
},
},
"delegateKerberosTicket": {
"title": "Delegate Kerberos Ticket",
- "description": "Delegate any Kerberos Ticket embedded in the Keycloak token to the API (via the Authorization header).",
+ "description": "Delegate any Kerberos ticket embedded in the Keycloak token to the API (via the authorization header).",
"type": "boolean",
"default": false,
"links": [
{
"href": "https://keycloak.github.io/docs/userguide/keycloak-server/html/kerberos.html#d4e3080",
- "rel": "Keycloak Kerberos credential delegation guide"
+ "rel": "Goto Keycloak Kerberos Credential Delegation Guide"
}
]
},

MODIFY
keycloak-oauth-policy/src/main/java/io/apiman/plugins/keycloak_oauth_policy/KeycloakOauthPolicy.java
keycloak-oauth-policy/src/main/java/io/apiman/plugins/keycloak_oauth_policy/KeycloakOauthPolicy.java
*/
package io.apiman.plugins.keycloak_oauth_policy;
-import java.util.Collections;
-
-import org.apache.commons.lang.StringUtils;
-import org.keycloak.RSATokenVerifier;
-import org.keycloak.common.VerificationException;
-import org.keycloak.common.constants.KerberosConstants;
-import org.keycloak.representations.AccessToken;
-import org.keycloak.representations.AccessToken.Access;
-
import io.apiman.gateway.engine.async.IAsyncResult;
import io.apiman.gateway.engine.async.IAsyncResultHandler;
import io.apiman.gateway.engine.beans.ApiRequest;
@@ -39,6 +30,18 @@ import io.apiman.plugins.keycloak_oauth_policy.beans.ForwardAuthInfo;
import io.apiman.plugins.keycloak_oauth_policy.beans.KeycloakOauthConfigBean;
import io.apiman.plugins.keycloak_oauth_policy.failures.PolicyFailureFactory;
import io.apiman.plugins.keycloak_oauth_policy.util.Holder;
+import org.apache.commons.lang.StringUtils;
+import org.keycloak.TokenVerifier;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.KeycloakDeploymentBuilder;
+import org.keycloak.adapters.rotation.JWKPublicKeyLocator;
+import org.keycloak.common.VerificationException;
+import org.keycloak.common.constants.KerberosConstants;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.AccessToken.Access;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+
+import java.util.Collections;
/**
* A Keycloak OAuth policy.
@@ -52,6 +55,8 @@ public class KeycloakOauthPolicy extends AbstractMappedPolicy<KeycloakOauthConfi
private static final String BEARER = "Bearer "; //$NON-NLS-1$
private static final String NEGOTIATE = "Negotiate "; //$NON-NLS-1$
private final PolicyFailureFactory failureFactory = new PolicyFailureFactory();
+ private KeycloakDeployment keycloakDeployment = null;
+ private String realmCache = null;
/**
* @see io.apiman.gateway.engine.policies.AbstractMappedPolicy#getConfigurationClass()
@@ -70,6 +75,10 @@ public class KeycloakOauthPolicy extends AbstractMappedPolicy<KeycloakOauthConfi
protected void doApply(final ApiRequest request, final IPolicyContext context,
final KeycloakOauthConfigBean config, final IPolicyChain<ApiRequest> chain) {
+ if (keycloakDeployment == null || checkIfRealmChanged(config.getRealm())) {
+ configureKeycloakDeployment(config);
+ }
+
final String rawToken = getRawAuthToken(request);
final Holder<Boolean> successStatus = new Holder<>(true);
@@ -135,8 +144,21 @@ public class KeycloakOauthPolicy extends AbstractMappedPolicy<KeycloakOauthConfi
IPolicyContext context, KeycloakOauthConfigBean config, IPolicyChain<ApiRequest> chain,
String rawToken) {
try {
- AccessToken parsedToken = RSATokenVerifier.verifyToken(rawToken, config.getRealmCertificate()
- .getPublicKey(), config.getRealm());
+
+ TokenVerifier<AccessToken> verifier = TokenVerifier.create(rawToken, AccessToken.class)
+ .withChecks(TokenVerifier.IS_ACTIVE);
+
+ AccessToken parsedToken;
+ if (config.getRealmCertificate() != null) {
+ parsedToken = verifier.publicKey(config.getRealmCertificate().getPublicKey())
+ .verify()
+ .getToken();
+ } else {
+ String kid = verifier.getHeader().getKeyId();
+ parsedToken = verifier.publicKey(keycloakDeployment.getPublicKeyLocator().getPublicKey(kid, keycloakDeployment))
+ .verify()
+ .getToken();
+ }
delegateKerberosTicket(request, config, parsedToken);
forwardHeaders(request, config, rawToken, parsedToken);
@@ -237,4 +259,40 @@ public class KeycloakOauthPolicy extends AbstractMappedPolicy<KeycloakOauthConfi
private ISharedStateComponent getDataStore(IPolicyContext context) {
return context.getComponent(ISharedStateComponent.class);
}
+
+ private void configureKeycloakDeployment(KeycloakOauthConfigBean config) {
+ if (config.getRealm() == null) return;
+
+ AdapterConfig adapterConfig = new AdapterConfig();
+ String realmUrl = getRealmUrl(config);
+ adapterConfig.setRealm(getRealmName(realmUrl));
+ adapterConfig.setAuthServerUrl(getAuthServerUrl(realmUrl));
+ // We just need a dummy resource because we only need the keycloak deployment for validating tokens
+ adapterConfig.setResource("api-gateway-api");
+
+ keycloakDeployment = KeycloakDeploymentBuilder.build(adapterConfig);
+ keycloakDeployment.setPublicKeyLocator(new JWKPublicKeyLocator());
+
+ realmCache = realmUrl;
+ }
+
+ private String getRealmUrl(KeycloakOauthConfigBean config) {
+ String realmUrl = config.getRealm();
+ if (realmUrl.endsWith("/")) {
+ realmUrl = realmUrl.substring(0, realmUrl.length() - 1);
+ }
+ return realmUrl;
+ }
+
+ private String getAuthServerUrl(String realmUrl) {
+ return StringUtils.substringBefore(realmUrl, "/realms/").trim();
+ }
+
+ private String getRealmName(String realmUrl) {
+ return StringUtils.substringAfter(realmUrl, "/realms/").trim();
+ }
+
+ private boolean checkIfRealmChanged(String realm) {
+ return !realmCache.equals(realm);
+ }
}

MODIFY
keycloak-oauth-policy/src/test/java/io/apiman/plugins/keycloak_oauth_policy/KeycloakOauthPolicyTest.java
keycloak-oauth-policy/src/test/java/io/apiman/plugins/keycloak_oauth_policy/KeycloakOauthPolicyTest.java
@@ -6,7 +6,11 @@ import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.apiman.gateway.engine.beans.ApiRequest;
import io.apiman.gateway.engine.beans.PolicyFailure;
import io.apiman.gateway.engine.components.IPolicyFailureFactoryComponent;
@@ -23,36 +27,37 @@ import io.apiman.plugins.keycloak_oauth_policy.beans.KeycloakOauthConfigBean;
import java.io.IOException;
import java.io.StringWriter;
import java.math.BigInteger;
-import java.security.InvalidKeyException;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.Security;
-import java.security.SignatureException;
+import java.security.*;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Base64;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import javax.security.auth.x500.X500Principal;
+import org.apache.commons.lang.StringUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.bouncycastle.x509.X509V1CertificateGenerator;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
+import org.junit.*;
+import org.keycloak.common.util.KeyUtils;
+import org.keycloak.common.util.PemUtils;
import org.keycloak.common.util.Time;
+import org.keycloak.jose.jwk.JSONWebKeySet;
+import org.keycloak.jose.jwk.JWK;
+import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessToken.Access;
import org.keycloak.representations.AddressClaimSet;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.junit.MockServerRule;
/**
* Test the {@link KeycloakOauthPolicy}.
@@ -78,6 +83,11 @@ public class KeycloakOauthPolicyTest {
private IPolicyContext mContext;
private ForwardRoles forwardRoles;
+ @Rule
+ // http://www.mock-server.com/mock_server/running_mock_server.html#junit_rule
+ public MockServerRule mockServerRule = new MockServerRule(this, 1080);
+ private MockServerClient mockServerClient = mockServerRule.getClient();
+
static {
if (Security.getProvider("BC") == null)
Security.addProvider(new BouncyCastleProvider());
@@ -135,6 +145,46 @@ public class KeycloakOauthPolicyTest {
// Data store
given(mContext.getComponent(ISharedStateComponent.class)).
willReturn(new InMemorySharedStateComponent());
+
+ initializeJwksBackend();
+
+ }
+
+ private void initializeJwksBackend() {
+ JWK[] jwk = {JWKBuilder.create()
+ .kid(KeyUtils.createKeyId(idpPair.getPublic()))
+ .algorithm("RS256")
+ .rsa(idpPair.getPublic(), idpCertificates[0])};
+ JSONWebKeySet jwks = new JSONWebKeySet();
+ jwks.setKeys(jwk);
+
+ String jwksString = null;
+ try {
+ jwksString = new ObjectMapper().writeValueAsString(jwks);
+ } catch (JsonProcessingException e) {
+ e.printStackTrace();
+ }
+
+ mockServerClient.when(request()
+ .withMethod("GET")
+ .withPath("/auth/realms/apiman-realm/protocol/openid-connect/certs"))
+ .respond(response().withStatusCode(200).withBody(jwksString));
+
+ mockServerClient.when(request()
+ .withMethod("GET")
+ .withPath("/auth/realms/apiman-realm/.well-known/openid-configuration"))
+ .respond(response().withStatusCode(200)
+ .withBody("{\n" +
+ " \"issuer\": \"http://localhost:1080/auth/realms/apiman-realm\",\n" +
+ " \"authorization_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/auth\",\n" +
+ " \"token_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/token\",\n" +
+ " \"token_introspection_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/token/introspect\",\n" +
+ " \"userinfo_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/userinfo\",\n" +
+ " \"end_session_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/logout\",\n" +
+ " \"jwks_uri\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/certs\",\n" +
+ " \"check_session_iframe\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/login-status-iframe.html\",\n" +
+ " \"introspection_endpoint\": \"http://localhost:1080/auth/realms/apiman-realm/protocol/openid-connect/token/introspect\"\n" +
+ "}"));
}
private String generateAndSerializeToken() throws CertificateEncodingException, IOException {
@@ -143,7 +193,35 @@ public class KeycloakOauthPolicyTest {
config.setRealm("apiman-realm");
config.setRealmCertificateString(certificateAsPem(idpCertificates[0]));
- return new JWSBuilder().jsonContent(token).rsa256(idpPair.getPrivate());
+ return new JWSBuilder().kid(KeyUtils.createKeyId(idpPair.getPublic())).jsonContent(token).rsa256(idpPair.getPrivate());
+ }
+
+ @Test
+ public void shouldSucceedWithValidJwksUrl() throws IOException, CertificateEncodingException {
+ String token = generateAndSerializeToken();
+
+ config.setRealmCertificateString(null);
+ config.setRealm("http://localhost:1080/auth/realms/apiman-realm/");
+
+ apiRequest.getQueryParams().put("access_token", token);
+ keycloakOauthPolicy.apply(apiRequest, mContext, config, mChain);
+
+ verify(mChain, times(1)).doApply(apiRequest);
+ verify(mChain, never()).doFailure(any(PolicyFailure.class));
+ }
+
+ @Test
+ public void shouldFailWithWrongToken() throws NoSuchAlgorithmException {
+ String encoded = new JWSBuilder().kid(KeyUtils.createKeyId(idpPair.getPublic())).jsonContent(token).rsa256(KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate());
+
+ config.setRealmCertificateString(null);
+ config.setRealm("http://localhost:1080/auth/realms/apiman-realm");
+
+ apiRequest.getQueryParams().put("access_token", encoded);
+ keycloakOauthPolicy.apply(apiRequest, mContext, config, mChain);
+
+ verify(mChain, times(1)).doFailure(any(PolicyFailure.class));
+ verify(mChain, never()).doApply(any(ApiRequest.class));
}
@Test

MODIFY
pom.xml
pom.xml
<version.commons-validator>1.6</version.commons-validator>
<version.com.google.guava>21.0</version.com.google.guava>
<version.com.fasterxml.jackson>2.10.0</version.com.fasterxml.jackson>
- <version.org.keycloak>2.0.0.Final</version.org.keycloak>
- <version.org.bouncycastle>1.52</version.org.bouncycastle>
+ <version.org.apache.httpcomponents>4.5.13</version.org.apache.httpcomponents>
+ <version.org.jboss.logging>3.4.1.Final</version.org.jboss.logging>
+ <version.org.keycloak>11.0.3</version.org.keycloak>
+ <version.org.bouncycastle>1.60</version.org.bouncycastle>
<version.org.json>20140107</version.org.json>
<version.org.mock-server>5.6.0</version.org.mock-server>
<version.io.jsonwebtoken.jjwt>0.9.1</version.io.jsonwebtoken.jjwt>
<version>${version.org.mock-server}</version>
</dependency>
<!-- others -->
+ <dependency>
+ <groupId>org.apache.httpcomponents</groupId>
+ <artifactId>httpclient</artifactId>
+ <version>${version.org.apache.httpcomponents}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.jboss.logging</groupId>
+ <artifactId>jboss-logging</artifactId>
+ <version>${version.org.jboss.logging}</version>
+ </dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<artifactId>guava</artifactId>
<version>${version.com.google.guava}</version>
</dependency>
+ <dependency>
+ <groupId>org.keycloak</groupId>
+ <artifactId>keycloak-adapter-core</artifactId>
+ <version>${version.org.keycloak}</version>
+ </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-common</artifactId>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
+ <version>3.0.0-M1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>

Support for JWKS