diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/CustomResource.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/CustomResource.java index bc96fe6c..8719cb44 100644 --- a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/CustomResource.java +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/CustomResource.java @@ -4,6 +4,7 @@ import com.github.thomasdarimont.keycloak.custom.endpoints.account.AcmeAccountResource; import com.github.thomasdarimont.keycloak.custom.endpoints.applications.ApplicationsInfoResource; import com.github.thomasdarimont.keycloak.custom.endpoints.credentials.UserCredentialsInfoResource; +import com.github.thomasdarimont.keycloak.custom.endpoints.filter.ProtectedResource; import com.github.thomasdarimont.keycloak.custom.endpoints.offline.OfflineSessionPropagationResource; import com.github.thomasdarimont.keycloak.custom.endpoints.profile.UserProfileResource; import com.github.thomasdarimont.keycloak.custom.endpoints.settings.UserSettingsResource; @@ -86,4 +87,9 @@ public AcmeAccountResource account() { public OfflineSessionPropagationResource sessionPropagation() { return resourceContext.initResource(new OfflineSessionPropagationResource(session, token)); } + + @Path("protected") + public ProtectedResource protectedResource() { + return resourceContext.initResource(new ProtectedResource(session)); + } } diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilter.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilter.java new file mode 100644 index 00000000..65fd4b69 --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilter.java @@ -0,0 +1,39 @@ +package com.github.thomasdarimont.keycloak.custom.endpoints.filter; + +import com.github.thomasdarimont.keycloak.custom.support.KeycloakSessionLookup; +import jakarta.annotation.Priority; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.representations.AccessToken; + +import java.io.IOException; +import java.util.regex.Pattern; + +import static com.github.thomasdarimont.keycloak.custom.endpoints.filter.TokenFilter.ACCESS_TOKEN_SESSION_KEY; + +@AzpFilterBinding +@JBossLog +@Priority(1) +@Provider +public class AzpFilter implements ContainerRequestFilter { + + private final Pattern clientPattern = Pattern.compile("acme.*"); + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + AccessToken accessToken = KeycloakSessionLookup.currentSession().getAttribute(ACCESS_TOKEN_SESSION_KEY, AccessToken.class); + + if (accessToken == null) { + log.error("AzpFilter must run after TokenFilter"); + throw new InternalServerErrorException(); + } + + if (!clientPattern.matcher(accessToken.getIssuedFor()).matches()) { + throw new ForbiddenException("This resource is only accessible for acme clients"); + } + } +} diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilterBinding.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilterBinding.java new file mode 100644 index 00000000..c362a8da --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/AzpFilterBinding.java @@ -0,0 +1,11 @@ +package com.github.thomasdarimont.keycloak.custom.endpoints.filter; + +import jakarta.ws.rs.NameBinding; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +public @interface AzpFilterBinding { +} diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/ProtectedResource.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/ProtectedResource.java new file mode 100644 index 00000000..4d75762e --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/ProtectedResource.java @@ -0,0 +1,58 @@ +package com.github.thomasdarimont.keycloak.custom.endpoints.filter; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Response; +import org.keycloak.models.KeycloakSession; + +import java.util.Map; + +import static com.github.thomasdarimont.keycloak.custom.endpoints.filter.TokenFilter.ACCESS_TOKEN_SESSION_KEY; + +/** + * Example for a resource protected by a request filter. + * Methods annotated with @AuthFilterBinding execute the filter before executing the method + */ +public class ProtectedResource { + + private final KeycloakSession keycloakSession; + + public ProtectedResource(KeycloakSession keycloakSession) { + this.keycloakSession = keycloakSession; + } + + /* + * Run a token check + */ + @GET + @Path("/protected") + @TokenFilterBinding + @Produces("application/json") + public Response protectedResource() { + return Response.ok(Map.of("secret", keycloakSession.getAttribute(ACCESS_TOKEN_SESSION_KEY))).build(); + } + + /* + * Run a token check AND an azp check + */ + @GET + @Path("/veryprotected") + @AzpFilterBinding + @TokenFilterBinding + @Produces("application/json") + public Response veryProtectedResource() { + return Response.ok(Map.of("supersecret", "The magic words are squeamish ossifrage", + "secret", keycloakSession.getAttribute(ACCESS_TOKEN_SESSION_KEY))).build(); + } + + /* + * Run no checks at all + */ + @GET + @Path("/public") + @Produces("application/json") + public Response openResource() { + return Response.ok(Map.of("secret", "no secrets here")).build(); + } +} diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilter.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilter.java new file mode 100644 index 00000000..88c84a36 --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilter.java @@ -0,0 +1,42 @@ +package com.github.thomasdarimont.keycloak.custom.endpoints.filter; + +import com.github.thomasdarimont.keycloak.custom.support.KeycloakSessionLookup; +import jakarta.annotation.Priority; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import org.keycloak.authorization.util.Tokens; +import org.keycloak.models.KeycloakSession; +import org.keycloak.representations.AccessToken; + +import java.io.IOException; + +/** + * Custom request filter for token handling. Executed if a request method is annotated with @AuthFilterBinding + */ +@Priority(0) +@Provider +@TokenFilterBinding +public class TokenFilter implements ContainerRequestFilter { + + public static final String ACCESS_TOKEN_SESSION_KEY = "acme-access-token"; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + // Important: The filter is instantiated only once. KeycloakSession must be local, not a field + KeycloakSession keycloakSession = KeycloakSessionLookup.currentSession(); + + AccessToken accessToken = Tokens.getAccessToken(keycloakSession); + + if (accessToken == null) { + throw new NotAuthorizedException("Invalid or missing token"); + } + + /* + * Store the access token in the keycloak session, so that it can be used by the actual + * resource without having to repeat the validation step + */ + keycloakSession.setAttribute(ACCESS_TOKEN_SESSION_KEY, accessToken); + } +} diff --git a/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilterBinding.java b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilterBinding.java new file mode 100644 index 00000000..16008e13 --- /dev/null +++ b/keycloak/extensions/src/main/java/com/github/thomasdarimont/keycloak/custom/endpoints/filter/TokenFilterBinding.java @@ -0,0 +1,11 @@ +package com.github.thomasdarimont.keycloak.custom.endpoints.filter; + +import jakarta.ws.rs.NameBinding; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +public @interface TokenFilterBinding { +}