From 28b52f16383c4430985df6e8c1be13c27b7e17d5 Mon Sep 17 00:00:00 2001 From: Ryan Emerson Date: Mon, 30 Mar 2026 16:08:57 +0100 Subject: [PATCH 1/3] AuthZen Evaluation API Closes #47629 Signed-off-by: Ryan Emerson --- .github/workflows/ci.yml | 20 + authzen/pom.xml | 21 + authzen/services/pom.xml | 34 + .../authorization/authzen/AuthZen.java | 85 ++ .../authzen/AuthZenRealmResourceProvider.java | 38 + .../AuthZenRealmResourceProviderFactory.java | 62 ++ .../authzen/AuthZenResource.java | 214 +++++ .../authzen/AuthZenWellKnownProvider.java | 53 ++ .../AuthZenWellKnownProviderFactory.java | 63 ++ ...ices.resource.RealmResourceProviderFactory | 17 + ...eycloak.wellknown.WellKnownProviderFactory | 17 + authzen/tests/authzen-client/pom.xml | 29 + .../authzen/client/AuthZenClient.java | 166 ++++ .../authzen/client/AuthZenClientSupplier.java | 62 ++ .../client/AuthZenTestFrameworkExtension.java | 30 + .../annotations/InjectAuthZenClient.java | 27 + ...cloak.testframework.TestFrameworkExtension | 1 + authzen/tests/base/pom.xml | 48 ++ .../authzen/AuthZenEvaluationInteropTest.java | 157 ++++ .../tests/authzen/AuthZenEvaluationTest.java | 789 ++++++++++++++++++ .../tests/authzen/AuthZenServerConfig.java | 31 + .../tests/authzen/AuthZenWellKnownTest.java | 78 ++ .../tests/authzen/authzen-interop-realm.json | 304 +++++++ .../decisions-authorization-api-1_0-01.json | 707 ++++++++++++++++ authzen/tests/pom.xml | 34 + authzen/tests/providers/pom.xml | 30 + .../providers/OwnerPolicyProvider.java | 90 ++ ...tion.policy.provider.PolicyProviderFactory | 1 + .../java/org/keycloak/common/Profile.java | 2 + pom.xml | 6 + .../quarkus/deployment/KeycloakProcessor.java | 1 + quarkus/runtime/pom.xml | 10 + .../keycloak/it/cli/dist/AuthZenDistTest.java | 46 + 33 files changed, 3273 insertions(+) create mode 100644 authzen/pom.xml create mode 100644 authzen/services/pom.xml create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProvider.java create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java create mode 100644 authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProviderFactory.java create mode 100644 authzen/services/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory create mode 100644 authzen/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory create mode 100644 authzen/tests/authzen-client/pom.xml create mode 100644 authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java create mode 100644 authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClientSupplier.java create mode 100644 authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenTestFrameworkExtension.java create mode 100644 authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/annotations/InjectAuthZenClient.java create mode 100644 authzen/tests/authzen-client/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension create mode 100644 authzen/tests/base/pom.xml create mode 100644 authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java create mode 100644 authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java create mode 100644 authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenServerConfig.java create mode 100644 authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java create mode 100644 authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json create mode 100644 authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json create mode 100644 authzen/tests/pom.xml create mode 100644 authzen/tests/providers/pom.xml create mode 100644 authzen/tests/providers/src/main/java/org/keycloak/tests/authzen/providers/OwnerPolicyProvider.java create mode 100644 authzen/tests/providers/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory create mode 100644 quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85c0166dbc40..a8e055825131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1212,6 +1212,25 @@ jobs: - name: Run tests run: ./mvnw package -f scim/tests/pom.xml + authzen-integration-tests: + name: AuthZen IT + runs-on: ubuntu-latest + needs: + - build + timeout-minutes: 45 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - id: integration-test-setup + name: Integration test setup + uses: ./.github/actions/integration-test-setup + + - name: Build tar keycloak-quarkus-dist + run: ./mvnw package -pl quarkus/server/,quarkus/dist/ + + - name: Run tests + run: ./mvnw package -f authzen/tests/pom.xml + admin-v2-tests: name: Admin v2 if: needs.conditional.outputs.ci-admin-v2 == 'true' @@ -1296,6 +1315,7 @@ jobs: - test-framework - base-new-integration-tests - mixed-cluster-compatibility-tests + - authzen-integration-tests - admin-v2-tests - testsuite-deprecation-check runs-on: ubuntu-latest diff --git a/authzen/pom.xml b/authzen/pom.xml new file mode 100644 index 000000000000..1013c2cb40ff --- /dev/null +++ b/authzen/pom.xml @@ -0,0 +1,21 @@ + + + + keycloak-parent + org.keycloak + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-authzen-parent + pom + Keycloak AuthZen Parent + The parent module for AuthZen support in Keycloak + + + services + tests + + diff --git a/authzen/services/pom.xml b/authzen/services/pom.xml new file mode 100644 index 000000000000..0a6a49741ecf --- /dev/null +++ b/authzen/services/pom.xml @@ -0,0 +1,34 @@ + + + + keycloak-parent + org.keycloak + 999.0.0-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-authzen-services + Keycloak AuthZen Services + This module provides the AuthZen resource endpoints + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + jakarta.ws.rs + jakarta.ws.rs-api + + + diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java new file mode 100644 index 000000000000..b9631db50b27 --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; + +public final class AuthZen { + + public static final String AUTHZEN_ACCESS_PATH = "access/v1"; + public static final String EVALUATION_PATH = AUTHZEN_ACCESS_PATH + "/evaluation"; + + private AuthZen() { + } + + public enum SubjectType { + CLIENT("client"), + USER("user"); + + private final String value; + + SubjectType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static SubjectType fromValue(String value) { + for (SubjectType type : values()) { + if (type.value.equals(value)) { + return type; + } + } + throw new IllegalArgumentException("Unsupported subject type: " + value); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Subject( + @JsonProperty(required = true) SubjectType type, + @JsonProperty(required = true) String id, + Map properties) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Resource( + @JsonProperty(required = true) String type, + @JsonProperty(required = true) String id, + Map properties) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Action(String name) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EvaluationRequest( + @JsonProperty(required = true) Subject subject, + @JsonProperty(required = true) Resource resource, + @JsonProperty(required = true) Action action, + Map context) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EvaluationResponse(boolean decision) {} +} diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProvider.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProvider.java new file mode 100644 index 000000000000..a8326b0073ff --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProvider.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class AuthZenRealmResourceProvider implements RealmResourceProvider { + + private final KeycloakSession session; + + public AuthZenRealmResourceProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public Object getResource() { + return new AuthZenResource(session); + } + + @Override + public void close() { + } +} diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java new file mode 100644 index 000000000000..d1095722fc79 --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.common.util.Environment; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class AuthZenRealmResourceProviderFactory implements RealmResourceProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "authzen"; + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new AuthZenRealmResourceProvider(session); + } + + @Override + public void init(Config.Scope config) { + if (!Environment.isDevMode() && config.root().getBoolean("http-enabled", false)) { + throw new IllegalArgumentException("The AuthZen feature requires HTTPS in order to be compliant with the OpenID AuthZen specification."); + } + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Feature.AUTHZEN); + } +} diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java new file mode 100644 index 000000000000..ca57f095612d --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java @@ -0,0 +1,214 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.attribute.Attributes; +import org.keycloak.authorization.common.ClientModelIdentity; +import org.keycloak.authorization.common.DefaultEvaluationContext; +import org.keycloak.authorization.identity.Identity; +import org.keycloak.authorization.identity.UserModelIdentity; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.model.Scope; +import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.store.ResourceStore; +import org.keycloak.authorization.store.ScopeStore; +import org.keycloak.authorization.store.StoreFactory; +import org.keycloak.authorization.util.Tokens; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.authorization.Permission; +import org.keycloak.util.JsonSerialization; + +public class AuthZenResource { + + private static final Response DECISION_FALSE = jakarta.ws.rs.core.Response.ok(new AuthZen.EvaluationResponse(false)).build(); + + private final KeycloakSession session; + + public AuthZenResource(KeycloakSession session) { + this.session = session; + } + + @POST + @Path(AuthZen.EVALUATION_PATH) + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response evaluate(AuthZen.EvaluationRequest request) { + AccessToken token = Tokens.getAccessToken(session); + if (token == null) { + throw new NotAuthorizedException("Bearer"); + } + + RealmModel realm = session.getContext().getRealm(); + AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); + StoreFactory storeFactory = authorization.getStoreFactory(); + + ClientModel client; + if (request.subject().type() == AuthZen.SubjectType.CLIENT) { + if (!request.subject().id().equals(token.getIssuedFor())) { + return DECISION_FALSE; + } + client = realm.getClientByClientId(request.subject().id()); + } else { + client = realm.getClientByClientId(token.getIssuedFor()); + } + + if (client == null || !client.isEnabled()) { + return DECISION_FALSE; + } + + ResourceServer resourceServer = storeFactory.getResourceServerStore().findByClient(client); + if (resourceServer == null) { + return DECISION_FALSE; + } + + Identity identity = resolveSubjectIdentity(realm, request); + if (identity == null) { + return DECISION_FALSE; + } + + ResourceStore resourceStore = storeFactory.getResourceStore(); + Resource resource = resourceStore.findByName(resourceServer, request.resource().id()); + if (resource == null) { + return DECISION_FALSE; + } + + String keycloakType = resource.getType() != null ? resource.getType() : ""; + if (!keycloakType.equals(request.resource().type())) { + return DECISION_FALSE; + } + + ScopeStore scopeStore = storeFactory.getScopeStore(); + Scope scope = scopeStore.findByName(resourceServer, request.action().name()); + if (scope == null) { + return DECISION_FALSE; + } + + Map> claims = null; + if (request.resource().properties() != null) { + claims = new HashMap<>(); + convertContext(request.resource().properties(), claims); + } + if (request.context() != null) { + if (claims == null) { + claims = new HashMap<>(); + } + convertContext(request.context(), claims); + } + + DefaultEvaluationContext context = new DefaultEvaluationContext(identity, claims, session); + ResourcePermission permission = new ResourcePermission(resource, List.of(scope), resourceServer); + + Collection granted = authorization.evaluators() + .from(List.of(permission), context) + .evaluate(resourceServer, null); + + boolean decision = !granted.isEmpty(); + return Response.ok(new AuthZen.EvaluationResponse(decision)).build(); + } + + private Identity resolveSubjectIdentity(RealmModel realm, AuthZen.EvaluationRequest request) { + AuthZen.Subject subject = request.subject(); + Identity identity = switch (subject.type()) { + case USER -> { + UserModel user = session.users().getUserById(realm, subject.id()); + yield user != null ? new UserModelIdentity(realm, user) : null; + } + case CLIENT -> { + ClientModel subjectClient = realm.getClientByClientId(subject.id()); + yield subjectClient != null ? new ClientModelIdentity(session, subjectClient) : null; + } + }; + if (request.subject().properties() != null && !request.subject().properties().isEmpty()) { + identity = withSubjectProperties(identity, request.subject().properties()); + } + return identity; + } + + private static Identity withSubjectProperties(Identity delegate, Map properties) { + Map> extra = new HashMap<>(); + convertValues(properties, extra); + + return new Identity() { + @Override + public String getId() { + return delegate.getId(); + } + + @Override + public Attributes getAttributes() { + Map> merged = new HashMap<>(delegate.getAttributes().toMap()); + merged.putAll(extra); + return Attributes.from(merged); + } + + @Override + public boolean hasRealmRole(String roleName) { + return delegate.hasRealmRole(roleName); + } + + @Override + public boolean hasClientRole(String clientId, String roleName) { + return delegate.hasClientRole(clientId, roleName); + } + + @Override + public boolean hasOneClientRole(String clientId, String... roleNames) { + return delegate.hasOneClientRole(clientId, roleNames); + } + }; + } + + private static void convertValues(Map source, Map> result) { + @SuppressWarnings("unchecked") + Map> target = (Map>) result; + for (Map.Entry entry : source.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Map || value instanceof List) { + try { + target.put(entry.getKey(), List.of(JsonSerialization.writeValueAsString(value))); + } catch (Exception e) { + target.put(entry.getKey(), List.of(String.valueOf(value))); + } + } else { + target.put(entry.getKey(), List.of(String.valueOf(value))); + } + } + } + + private static void convertContext(Map context, Map> result) { + convertValues(context, result); + } +} diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java new file mode 100644 index 000000000000..056f17fcb15f --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import java.util.Map; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.Urls; +import org.keycloak.urls.UrlType; +import org.keycloak.wellknown.WellKnownProvider; + +public class AuthZenWellKnownProvider implements WellKnownProvider { + + private final KeycloakSession session; + + public AuthZenWellKnownProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public Object getConfig() { + RealmModel realm = session.getContext().getRealm(); + String realmUri = Urls.realmIssuer(session.getContext().getUri(UrlType.FRONTEND).getBaseUri(), realm.getName()); + + return Map.of( + "policy_decision_point", realmUri, + "access_evaluation_endpoint", accessEvaluationEndpoint(realmUri) + ); + } + + public static String accessEvaluationEndpoint(String realmUri) { + return String.format("%s/%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID, AuthZen.EVALUATION_PATH); + } + + @Override + public void close() { + } +} diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProviderFactory.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProviderFactory.java new file mode 100644 index 000000000000..0ce92a214dc8 --- /dev/null +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProviderFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.authzen; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.wellknown.WellKnownProvider; +import org.keycloak.wellknown.WellKnownProviderFactory; + +public class AuthZenWellKnownProviderFactory implements WellKnownProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "authzen-configuration"; + + @Override + public WellKnownProvider create(KeycloakSession session) { + return new AuthZenWellKnownProvider(session); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isAvailableViaServerMetadata() { + return true; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Feature.AUTHZEN); + } +} diff --git a/authzen/services/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/authzen/services/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 000000000000..9301bc83e486 --- /dev/null +++ b/authzen/services/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1,17 @@ +# +# Copyright 2025 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +org.keycloak.authorization.authzen.AuthZenRealmResourceProviderFactory \ No newline at end of file diff --git a/authzen/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory b/authzen/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory new file mode 100644 index 000000000000..06660b830e7d --- /dev/null +++ b/authzen/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory @@ -0,0 +1,17 @@ +# +# Copyright 2025 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +org.keycloak.authorization.authzen.AuthZenWellKnownProviderFactory \ No newline at end of file diff --git a/authzen/tests/authzen-client/pom.xml b/authzen/tests/authzen-client/pom.xml new file mode 100644 index 000000000000..de0c4adaa108 --- /dev/null +++ b/authzen/tests/authzen-client/pom.xml @@ -0,0 +1,29 @@ + + + + keycloak-authzen-tests-parent + org.keycloak.tests + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-test-framework-authzen-client + Keycloak Test Framework - AuthZen Client extension + jar + AuthZen client extension for Keycloak Test Framework + + + + org.keycloak + keycloak-authzen-services + compile + + + org.keycloak.testframework + keycloak-test-framework-core + compile + + + diff --git a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java new file mode 100644 index 000000000000..5a3abbe042cf --- /dev/null +++ b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testframework.authzen.client; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import org.keycloak.authorization.authzen.AuthZen; +import org.keycloak.http.simple.SimpleHttp; +import org.keycloak.http.simple.SimpleHttpRequest; +import org.keycloak.http.simple.SimpleHttpResponse; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +public class AuthZenClient { + + private final SimpleHttp simpleHttp; + private final String realmUrl; + + public AuthZenClient(SimpleHttp simpleHttp, String realmUrl) { + this.simpleHttp = simpleHttp; + this.realmUrl = realmUrl; + } + + public Authenticated withAccessToken(String accessToken) { + return new Authenticated(simpleHttp, realmUrl, accessToken); + } + + public EvaluationResult evaluate(AuthZen.EvaluationRequest request) throws IOException { + String url = realmUrl + "/authzen/access/v1/evaluation"; + + try (SimpleHttpResponse response = req(simpleHttp.doPost(url).json(request))) { + int status = response.getStatus(); + AuthZen.EvaluationResponse body = response.asJson(AuthZen.EvaluationResponse.class); + return new EvaluationResult(status, body); + } + } + + public WellKnownResponse fetchWellKnownConfiguration() throws IOException { + String url = realmUrl + "/.well-known/authzen-configuration"; + try (SimpleHttpResponse rsp = req(simpleHttp.doGet(url))) { + return rsp.asJson(WellKnownResponse.class); + } + } + + protected SimpleHttpResponse req(SimpleHttpRequest request) throws IOException { + request.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + request.acceptJson(); + return request.asResponse(); + } + + public static class Authenticated extends AuthZenClient { + + private final String accessToken; + + private Authenticated(SimpleHttp simpleHttp, String realmUrl, String accessToken) { + super(simpleHttp, realmUrl); + this.accessToken = accessToken; + } + + @Override + protected SimpleHttpResponse req(SimpleHttpRequest request) throws IOException { + return super.req(request.auth(accessToken)); + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record WellKnownResponse( + String policyDecisionPoint, + String accessEvaluationEndpoint, + String accessEvaluationsEndpoint + ) { + } + + public record EvaluationResult(int statusCode, AuthZen.EvaluationResponse response) { + + public boolean decision() { + return response.decision(); + } + } + + public static EvaluationRequestBuilder evaluationRequest() { + return new EvaluationRequestBuilder(); + } + + public static final class EvaluationRequestBuilder { + private AuthZen.SubjectType subjectType; + private String subjectId; + private Map subjectProperties; + private boolean subjectSet; + private String resourceType; + private String resourceId; + private Map resourceProperties; + private boolean resourceSet; + private AuthZen.Action action; + private Map contextProperties; + + public EvaluationRequestBuilder subject(AuthZen.SubjectType type, String id) { + this.subjectType = type; + this.subjectId = id; + this.subjectSet = true; + return this; + } + + public EvaluationRequestBuilder subjectProperty(String key, Object value) { + if (subjectProperties == null) { + subjectProperties = new HashMap<>(); + } + subjectProperties.put(key, value); + return this; + } + + public EvaluationRequestBuilder resource(String type, String id) { + this.resourceType = type; + this.resourceId = id; + this.resourceSet = true; + return this; + } + + public EvaluationRequestBuilder resourceProperty(String key, Object value) { + if (resourceProperties == null) { + resourceProperties = new HashMap<>(); + } + resourceProperties.put(key, value); + return this; + } + + public EvaluationRequestBuilder action(String name) { + this.action = new AuthZen.Action(name); + return this; + } + + public EvaluationRequestBuilder contextProperty(String key, Object value) { + if (contextProperties == null) { + contextProperties = new HashMap<>(); + } + contextProperties.put(key, value); + return this; + } + + public AuthZen.EvaluationRequest build() { + AuthZen.Subject subject = subjectSet ? new AuthZen.Subject(subjectType, subjectId, subjectProperties) : null; + AuthZen.Resource resource = resourceSet ? new AuthZen.Resource(resourceType, resourceId, resourceProperties) : null; + return new AuthZen.EvaluationRequest(subject, resource, action, contextProperties); + } + } +} diff --git a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClientSupplier.java b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClientSupplier.java new file mode 100644 index 000000000000..490c56555cc0 --- /dev/null +++ b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClientSupplier.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testframework.authzen.client; + +import java.util.List; + +import org.keycloak.http.simple.SimpleHttp; +import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; +import org.keycloak.testframework.injection.DependenciesBuilder; +import org.keycloak.testframework.injection.Dependency; +import org.keycloak.testframework.injection.InstanceContext; +import org.keycloak.testframework.injection.RequestedInstance; +import org.keycloak.testframework.injection.Supplier; +import org.keycloak.testframework.injection.SupplierOrder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.server.KeycloakServer; + +public class AuthZenClientSupplier implements Supplier { + + @Override + public AuthZenClient getValue(InstanceContext instanceContext) { + SimpleHttp simpleHttp = instanceContext.getDependency(SimpleHttp.class); + KeycloakServer server = instanceContext.getDependency(KeycloakServer.class); + ManagedRealm managedRealm = instanceContext.getDependency(ManagedRealm.class); + + String realmUrl = server.getBaseUrl() + "/realms/" + managedRealm.getName(); + return new AuthZenClient(simpleHttp, realmUrl); + } + + @Override + public boolean compatible(InstanceContext a, RequestedInstance b) { + return true; + } + + @Override + public List getDependencies(RequestedInstance instanceContext) { + return DependenciesBuilder.create(SimpleHttp.class).add(KeycloakServer.class).add(ManagedRealm.class).build(); + } + + @Override + public void close(InstanceContext instanceContext) { + } + + @Override + public int order() { + return SupplierOrder.BEFORE_REALM; + } +} diff --git a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenTestFrameworkExtension.java b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenTestFrameworkExtension.java new file mode 100644 index 000000000000..6534dd31a8e0 --- /dev/null +++ b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenTestFrameworkExtension.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testframework.authzen.client; + +import java.util.List; + +import org.keycloak.testframework.TestFrameworkExtension; +import org.keycloak.testframework.injection.Supplier; + +public class AuthZenTestFrameworkExtension implements TestFrameworkExtension { + + @Override + public List> suppliers() { + return List.of(new AuthZenClientSupplier()); + } +} diff --git a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/annotations/InjectAuthZenClient.java b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/annotations/InjectAuthZenClient.java new file mode 100644 index 000000000000..7c764a849ec3 --- /dev/null +++ b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/annotations/InjectAuthZenClient.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testframework.authzen.client.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface InjectAuthZenClient { +} diff --git a/authzen/tests/authzen-client/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension b/authzen/tests/authzen-client/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension new file mode 100644 index 000000000000..724f8b748599 --- /dev/null +++ b/authzen/tests/authzen-client/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension @@ -0,0 +1 @@ +org.keycloak.testframework.authzen.client.AuthZenTestFrameworkExtension \ No newline at end of file diff --git a/authzen/tests/base/pom.xml b/authzen/tests/base/pom.xml new file mode 100644 index 000000000000..b02d18553675 --- /dev/null +++ b/authzen/tests/base/pom.xml @@ -0,0 +1,48 @@ + + + + keycloak-authzen-tests-parent + org.keycloak.tests + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-authzen-tests-base + Keycloak AuthZen Base Testsuite + jar + Keycloak AuthZen Base Testsuite + + + + org.keycloak.tests + keycloak-test-framework-authzen-client + ${project.version} + test + + + org.keycloak.tests + keycloak-tests-utils-shared + ${project.version} + test + + + org.keycloak.testframework + keycloak-test-framework-oauth + test + + + org.keycloak.tests + keycloak-tests-custom-providers + ${project.version} + test + + + org.keycloak.tests + keycloak-authzen-tests-providers + ${project.version} + test + + + diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java new file mode 100644 index 000000000000..388bba71f8a6 --- /dev/null +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.authzen; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.keycloak.authorization.authzen.AuthZen; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.authzen.client.AuthZenClient; +import org.keycloak.testframework.authzen.client.AuthZenClient.EvaluationResult; +import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.util.JsonSerialization; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Runs the AuthZen interop evaluation test suite defined in the OpenID AuthZen interop project. + * Test scenarios are loaded from the decisions-authorization-api-1_0-01.json resource file. + * Each entry in the JSON defines an AuthZen evaluation request and its expected decision. + *

+ * All required Realm configuration including users, roles, client, and authorization settings is loaded from interop-realm.json. + */ +@KeycloakIntegrationTest(config = AuthZenServerConfig.class) +public class AuthZenEvaluationInteropTest { + + // Interop project subject IDs (base64-encoded, too long for Keycloak's VARCHAR(36) ID column) + private static final String INTEROP_RICK_ID = "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs"; + private static final String INTEROP_MORTY_ID = "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs"; + private static final String INTEROP_SUMMER_ID = "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs"; + private static final String INTEROP_BETH_ID = "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs"; + private static final String INTEROP_JERRY_ID = "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs"; + + // Keycloak-compatible UUIDs for each user + private static final String RICK_ID = "fd0614d3-c39a-4781-b7bd-8b96f5a5100d"; + private static final String MORTY_ID = "fd1614d3-c39a-4781-b7bd-8b96f5a5100d"; + private static final String SUMMER_ID = "fd2614d3-c39a-4781-b7bd-8b96f5a5100d"; + private static final String BETH_ID = "fd3614d3-c39a-4781-b7bd-8b96f5a5100d"; + private static final String JERRY_ID = "fd4614d3-c39a-4781-b7bd-8b96f5a5100d"; + + // Maps interop subject IDs to Keycloak UUIDs + private static final Map INTEROP_TO_KC_ID = Map.of( + INTEROP_RICK_ID, RICK_ID, + INTEROP_MORTY_ID, MORTY_ID, + INTEROP_SUMMER_ID, SUMMER_ID, + INTEROP_BETH_ID, BETH_ID, + INTEROP_JERRY_ID, JERRY_ID + ); + + private static final Map USER_NAMES = Map.of( + RICK_ID, "rick", + MORTY_ID, "morty", + SUMMER_ID, "summer", + BETH_ID, "beth", + JERRY_ID, "jerry" + ); + + private static final String PDP_CLIENT_ID = "authzen-pdp"; + private static final String PDP_CLIENT_SECRET = "authzen-pdp-secret"; + + @InjectRealm(fromJson = "authzen-interop-realm.json") + ManagedRealm realm; + + @InjectClient(attachTo = PDP_CLIENT_ID) + ManagedClient pdpClient; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectAuthZenClient + AuthZenClient authZenClient; + + @TestFactory + Stream interopEvaluationTests() throws IOException { + List decisions = loadDecisions(); + + AccessTokenResponse tokenResponse = oauth + .client(PDP_CLIENT_ID, PDP_CLIENT_SECRET) + .doClientCredentialsGrantAccessTokenRequest(); + AuthZenClient.Authenticated client = authZenClient.withAccessToken(tokenResponse.getAccessToken()); + + return decisions.stream().map(decision -> { + String testName = buildTestName(decision); + return DynamicTest.dynamicTest(testName, () -> { + EvaluationResult result = client.evaluate(decision.request()); + assertEquals(200, result.statusCode(), "Expected 200 OK for: " + testName); + assertEquals(decision.expected(), result.decision(), "Expected decision=" + decision.expected() + " for: " + testName); + }); + }); + } + + private static String buildTestName(InteropDecision decision) { + AuthZen.EvaluationRequest req = decision.request(); + String userName = USER_NAMES.getOrDefault(req.subject().id(), req.subject().id()); + String action = req.action().name(); + String resourceType = req.resource().type(); + String resourceId = req.resource().id(); + String expected = decision.expected() ? "ALLOW" : "DENY"; + return String.format("%s | %s %s:%s => %s", userName, action, resourceType, resourceId, expected); + } + + private static List loadDecisions() throws IOException { + try (InputStream is = AuthZenEvaluationInteropTest.class.getResourceAsStream( + "decisions-authorization-api-1_0-01.json")) { + com.fasterxml.jackson.databind.ObjectMapper mapper = JsonSerialization.mapper; + mapper = mapper.copy().configure( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + InteropDecisions decisions = mapper.readValue(is, InteropDecisions.class); + return decisions.evaluation().stream() + .map(AuthZenEvaluationInteropTest::remapSubjectId) + .toList(); + } + } + + private static InteropDecision remapSubjectId(InteropDecision decision) { + AuthZen.EvaluationRequest req = decision.request(); + String kcId = INTEROP_TO_KC_ID.get(req.subject().id()); + if (kcId == null) { + return decision; + } + AuthZen.Subject remapped = new AuthZen.Subject(req.subject().type(), kcId, req.subject().properties()); + return new InteropDecision(new AuthZen.EvaluationRequest(remapped, req.resource(), req.action(), req.context()), decision.expected()); + } + + public record InteropDecisions(List evaluation) { + } + + public record InteropDecision(AuthZen.EvaluationRequest request, boolean expected) { + } +} diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java new file mode 100644 index 000000000000..2bc653032d56 --- /dev/null +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java @@ -0,0 +1,789 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.authzen; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.resource.AuthorizationResource; +import org.keycloak.http.simple.SimpleHttp; +import org.keycloak.http.simple.SimpleHttpResponse; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectSimpleHttp; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.testframework.authzen.client.AuthZenClient; +import org.keycloak.testframework.authzen.client.AuthZenClient.EvaluationResult; +import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ClientConfig; +import org.keycloak.testframework.realm.ClientConfigBuilder; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.server.KeycloakUrls; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.apache.http.entity.StringEntity; +import org.junit.jupiter.api.Test; + +import static org.keycloak.authorization.authzen.AuthZen.SubjectType.CLIENT; +import static org.keycloak.authorization.authzen.AuthZen.SubjectType.USER; +import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.accessEvaluationEndpoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = AuthZenServerConfig.class) +public class AuthZenEvaluationTest { + + private static final String ADMIN_USER_ID = "a0000000-0000-0000-0000-000000000001"; + private static final String REGULAR_USER_ID = "a0000000-0000-0000-0000-000000000002"; + + @InjectRealm(config = TestRealmConfig.class) + ManagedRealm realm; + + @InjectClient(ref = "authzen-client", config = AuthzClientConfig.class) + ManagedClient client; + + @InjectClient(ref = "no-authz-client", config = NoAuthzClientConfig.class) + ManagedClient noAuthzClient; + + @InjectClient(ref = "subject-id-client", config = AuthzClientConfig.class) + ManagedClient subjectClient; + + @InjectClient(ref = "disableable-client", config = AuthzClientConfig.class) + ManagedClient disableableClient; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectAuthZenClient + AuthZenClient authZenClient; + + @InjectSimpleHttp + SimpleHttp simpleHttp; + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + @TestSetup + public void setup() { + configureAuthorizationResources(); + } + + // Resource properties are not used by Keycloak but are accepted and ignored per the AuthZen spec + @Test + public void testAdminUserAccessAdminResource() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .resourceProperty("ignored", "property") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testRegularUserDeniedAdminResource() throws IOException { + EvaluationResult result = authzenClient("regular-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, REGULAR_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testAdminUserAccessUsersResource() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/users") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testRegularUserAccessUsersResource() throws IOException { + EvaluationResult result = authzenClient("regular-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, REGULAR_USER_ID) + .action("read") + .resource("endpoint", "/users") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testContextPassedToClaims() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/context-protected") + .contextProperty("environment", "production") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testContextMismatchDeniesAccess() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/context-protected") + .contextProperty("environment", "staging") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testMissingContextDeniesAccess() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/context-protected") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testNestedContextPassedToClaims() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/nested-context") + .contextProperty("request", Map.of("ip", "10.0.0.1")) + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testNestedContextMismatchDeniesAccess() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/nested-context") + .contextProperty("request", Map.of("ip", "192.168.1.1")) + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testUnknownResourceReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/nonexistent") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + // Resource exists but the requested type does not match the resource's configured type + @Test + public void testResourceTypeMismatchReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("wrong-type", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + // Resource has an empty type in Keycloak; an empty type string in the request should match + @Test + public void testEmptyTypeResourceMatchesEmptyType() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("", "/empty-type") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + // The /scope-limited resource defines both "read" and "write" scopes, but only the "read" + // scope has a permission granting access. Requesting the "write" action should be denied. + @Test + public void testActionWithoutPermissionDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("write") + .resource("endpoint", "/scope-limited") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + // The authenticated user (regular-user) evaluates whether a different user subject (admin-user) + // is authorized to access a protected resource + @Test + public void testClientEvaluatesUserSubjectAuthorized() throws IOException { + EvaluationResult result = authzenClient("regular-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + // The authenticated client (authzen-client) evaluates whether a user subject without + // the required role is denied access + @Test + public void testClientEvaluatesUserSubjectDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, REGULAR_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + // A CLIENT subject whose id matches the token's client evaluates against its own resource server + @Test + public void testClientSubjectMatchingTokenAuthorized() throws IOException { + AccessTokenResponse tokenResponse = oauth + .client(subjectClient.getClientId(), subjectClient.getSecret()) + .doClientCredentialsGrantAccessTokenRequest(); + + EvaluationResult result = authZenClient.withAccessToken(tokenResponse.getAccessToken()) + .evaluate(AuthZenClient.evaluationRequest() + .subject(CLIENT, "subject-id-client") + .action("read") + .resource("endpoint", "/subject-only") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + // A CLIENT subject whose id does not match the token's client returns decision:false + @Test + public void testClientSubjectMismatchingTokenReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(CLIENT, "subject-id-client") + .action("read") + .resource("endpoint", "/subject-only") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + // A client obtains a token, is then disabled, and attempts to use the stale token to request authorization. + @Test + public void testDisabledClientWithStaleTokenReturnsUnauthorized() throws IOException { + AccessTokenResponse tokenResponse = oauth + .client(disableableClient.getClientId(), disableableClient.getSecret()) + .doClientCredentialsGrantAccessTokenRequest(); + + disableableClient.updateWithCleanup(c -> c.enabled(false)); + + EvaluationResult result = authZenClient.withAccessToken(tokenResponse.getAccessToken()) + .evaluate(AuthZenClient.evaluationRequest() + .subject(CLIENT, "disableable-client") + .action("read") + .resource("endpoint", "/open") + .build()); + + assertEquals(401, result.statusCode()); + } + + // Subject properties are merged into identity attributes. The /subject-protected resource + // requires a "department" identity attribute matching "engineering". + @Test + public void testSubjectPropertiesPassedToIdentityAttributes() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .subjectProperty("department", "engineering") + .action("read") + .resource("endpoint", "/subject-protected") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testSubjectPropertiesMismatchDeniesAccess() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .subjectProperty("department", "marketing") + .action("read") + .resource("endpoint", "/subject-protected") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testMissingSubjectPropertiesDeniesAccess() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/subject-protected") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testUnauthenticatedUserReturnsUnauthorized() throws IOException { + EvaluationResult result = authZenClient.evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(401, result.statusCode()); + } + + @Test + public void testMissingSubjectReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMissingSubjectTypeReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(null, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMissingSubjectIdReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, null) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testUnknownUserSubjectReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "nonexistent-user") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testUnknownClientSubjectReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(CLIENT, "nonexistent-client") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testInvalidSubjectTypeReturnsBadRequest() throws IOException { + String url = accessEvaluationEndpoint(realmUrl()); + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest("admin-user", "password"); + + String json = """ + {"subject":{"type":"invalid-type","id":"%s"},\ + "resource":{"type":"endpoint","id":"/admin"},\ + "action":{"name":"read"}}""".formatted(ADMIN_USER_ID); + + try (SimpleHttpResponse response = simpleHttp.doPost(url) + .auth(tokenResponse.getAccessToken()) + .header("Content-Type", "application/json") + .entity(new StringEntity(json)) + .asResponse()) { + assertEquals(400, response.getStatus()); + } + } + + @Test + public void testMissingResourceReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMissingResourceTypeReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource(null, "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMissingResourceIdReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", null) + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMissingActionReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testUndefinedActionReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("undefined-action") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testClientWithoutAuthorizationReturnsDenied() throws IOException { + AccessTokenResponse tokenResponse = oauth + .client(noAuthzClient.getClientId(), noAuthzClient.getSecret()) + .doPasswordGrantRequest("admin-user", "password"); + + EvaluationResult result = authZenClient.withAccessToken(tokenResponse.getAccessToken()) + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER_ID) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testInvalidJsonReturnsBadRequest() throws IOException { + String url = accessEvaluationEndpoint(realmUrl()); + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest("admin-user", "password"); + + try (SimpleHttpResponse response = simpleHttp.doPost(url) + .auth(tokenResponse.getAccessToken()) + .header("Content-Type", "application/json") + .entity(new StringEntity("{invalid json")) + .asResponse()) { + assertEquals(400, response.getStatus()); + } + } + + private String realmUrl() { + return keycloakUrls.getBase() + "/realms/" + realm.getName(); + } + + private AuthZenClient.Authenticated authzenClient(String username, String password) { + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest(username, password); + return authZenClient.withAccessToken(tokenResponse.getAccessToken()); + } + + private void configureAuthorizationResources() { + configureAuthzenClientResources(); + configureSubjectClientResources(); + configureDisableableClientResources(); + } + + // authzen-client is the OAuth client used to authenticate all AuthZen API requests (bearer token). + // For USER subject evaluations, it also acts as the resource server — resources and policies + // are resolved from this client because the resource server is determined by token.getIssuedFor(). + private void configureAuthzenClientResources() { + AuthorizationResource authz = client.admin().authorization(); + String adminRoleId = realm.admin().roles().get("admin").toRepresentation().getId(); + + createScope(authz, "read"); + createScope(authz, "write"); + + String adminPolicyId = createRolePolicy(authz, "Require Admin Role", adminRoleId); + String alwaysGrantId = createAlwaysGrantPolicy(authz); + + createResource(authz, "/admin", "endpoint", "read"); + createResourcePermission(authz, "Admin Resource Permission", "/admin", adminPolicyId); + + createResource(authz, "/users", "endpoint", "read"); + createResourcePermission(authz, "Users Resource Permission", "/users", alwaysGrantId); + + createResource(authz, "/scope-limited", "endpoint", "read", "write"); + createScopePermission(authz, "Scope Limited Read Permission", "/scope-limited", "read", "Always Grant"); + + createResource(authz, "/context-protected", "endpoint", "read"); + String envPolicyId = createRegexPolicy(authz, "Context Environment Policy", "environment", "production", true); + createResourcePermission(authz, "Context Protected Permission", "/context-protected", envPolicyId); + + createResource(authz, "/nested-context", "endpoint", "read"); + String ipPolicyId = createRegexPolicy(authz, "Nested Context IP Policy", "request.ip", "10\\.0\\.0\\..*", true); + createResourcePermission(authz, "Nested Context Permission", "/nested-context", ipPolicyId); + + createResource(authz, "/empty-type", null, "read"); + createResourcePermission(authz, "Empty Type Resource Permission", "/empty-type", alwaysGrantId); + + createResource(authz, "/subject-protected", "endpoint", "read"); + String deptPolicyId = createRegexPolicy(authz, "Subject Department Policy", "department", "engineering", false); + createResourcePermission(authz, "Subject Protected Permission", "/subject-protected", deptPolicyId); + } + + // subject-id-client is used as the subject in CLIENT-type evaluations (subject.id = "subject-id-client"). + // When subject.type is CLIENT, the resource server is resolved from subject.id rather than the + // token's client — so this client's own resources and policies are evaluated, not authzen-client's. + private void configureSubjectClientResources() { + AuthorizationResource authz = subjectClient.admin().authorization(); + + createScope(authz, "read"); + + String alwaysGrantId = createAlwaysGrantPolicy(authz); + + // /subject-only - only exists on subject-id-client's resource server + createResource(authz, "/subject-only", "endpoint", "read"); + createResourcePermission(authz, "Subject Only Permission", "/subject-only", alwaysGrantId); + } + + // disableable-client is a dedicated client used to test that a disabled client with a + // previously obtained token is denied. Isolated from subject-id-client so that disabling + // it does not affect other tests. + private void configureDisableableClientResources() { + AuthorizationResource authz = disableableClient.admin().authorization(); + + createScope(authz, "read"); + + String alwaysGrantId = createAlwaysGrantPolicy(authz); + + createResource(authz, "/open", "endpoint", "read"); + createResourcePermission(authz, "Open Permission", "/open", alwaysGrantId); + } + + private static void createScope(AuthorizationResource authz, String name) { + try (Response response = authz.scopes().create(new ScopeRepresentation(name))) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static void createResource(AuthorizationResource authz, String name, String type, String... scopes) { + ResourceRepresentation resource = new ResourceRepresentation(); + resource.setName(name); + resource.setType(type); + resource.addScope(scopes); + try (Response response = authz.resources().create(resource)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static String createRolePolicy(AuthorizationResource authz, String name, String roleId) { + RolePolicyRepresentation policy = new RolePolicyRepresentation(); + policy.setName(name); + policy.addRole(roleId); + try (Response response = authz.policies().role().create(policy)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + return authz.policies().role().findByName(name).getId(); + } + + private static String createAlwaysGrantPolicy(AuthorizationResource authz) { + PolicyRepresentation policy = new PolicyRepresentation(); + policy.setName("Always Grant"); + policy.setType("always-grant"); + try (Response response = authz.policies().create(policy)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + return authz.policies().findByName("Always Grant").getId(); + } + + private static String createRegexPolicy(AuthorizationResource authz, String name, + String claim, String pattern, boolean targetContextAttributes) { + RegexPolicyRepresentation policy = new RegexPolicyRepresentation(); + policy.setName(name); + policy.setTargetClaim(claim); + policy.setPattern(pattern); + policy.setTargetContextAttributes(targetContextAttributes); + try (Response response = authz.policies().regex().create(policy)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + return authz.policies().findByName(name).getId(); + } + + private static void createResourcePermission(AuthorizationResource authz, String name, + String resourceName, String policyId) { + ResourcePermissionRepresentation permission = ResourcePermissionRepresentation.create() + .name(name) + .resources(Set.of(authz.resources().findByName(resourceName).get(0).getId())) + .policies(Set.of(policyId)) + .build(); + try (Response response = authz.permissions().resource().create(permission)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static void createScopePermission(AuthorizationResource authz, String name, + String resourceName, String scopeName, String policyName) { + ScopePermissionRepresentation permission = new ScopePermissionRepresentation(); + permission.setName(name); + permission.setResources(Set.of(authz.resources().findByName(resourceName).get(0).getId())); + permission.setScopes(Set.of(authz.scopes().findByName(scopeName).getId())); + permission.addPolicy(policyName); + try (Response response = authz.permissions().scope().create(permission)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + public static class TestRealmConfig implements RealmConfig { + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + realm.addRole("admin"); + + realm.addUser("admin-user") + .id(ADMIN_USER_ID) + .name("Admin", "User") + .email("admin@localhost") + .password("password") + .roles("admin"); + + realm.addUser("regular-user") + .id(REGULAR_USER_ID) + .name("Regular", "User") + .email("regular@localhost") + .password("password"); + + return realm; + } + } + + public static class AuthzClientConfig implements ClientConfig { + @Override + public ClientConfigBuilder configure(ClientConfigBuilder client) { + return client + .secret("secret") + .directAccessGrantsEnabled(true) + .authorizationServicesEnabled(true); + } + } + + public static class NoAuthzClientConfig implements ClientConfig { + @Override + public ClientConfigBuilder configure(ClientConfigBuilder client) { + return client + .secret("secret") + .directAccessGrantsEnabled(true); + } + } +} diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenServerConfig.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenServerConfig.java new file mode 100644 index 000000000000..c2944881b29c --- /dev/null +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenServerConfig.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.authzen; + +import org.keycloak.common.Profile.Feature; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; + +public class AuthZenServerConfig implements KeycloakServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Feature.AUTHZEN) + .dependency("org.keycloak.tests", "keycloak-tests-custom-providers") + .dependency("org.keycloak.tests", "keycloak-authzen-tests-providers"); + } +} diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java new file mode 100644 index 000000000000..da0e03c2e474 --- /dev/null +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.authzen; + +import java.io.IOException; + +import org.keycloak.http.simple.SimpleHttp; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectSimpleHttp; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.authzen.client.AuthZenClient; +import org.keycloak.testframework.authzen.client.AuthZenClient.WellKnownResponse; +import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.server.KeycloakUrls; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@KeycloakIntegrationTest(config = AuthZenServerConfig.class) +public class AuthZenWellKnownTest { + + @InjectRealm + ManagedRealm realm; + + @InjectAuthZenClient + AuthZenClient authZenClient; + + @InjectSimpleHttp + SimpleHttp simpleHttp; + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + @Test + public void testRealmWellKnownEndpoint() throws IOException { + WellKnownResponse response = authZenClient.fetchWellKnownConfiguration(); + + assertWellKnownResponse(response); + } + + @Test + public void testServerMetadataWellKnownEndpoint() throws IOException { + String url = keycloakUrls.getBase() + "/.well-known/authzen-configuration/realms/" + realm.getName(); + WellKnownResponse response = simpleHttp.doGet(url).asJson(WellKnownResponse.class); + + assertWellKnownResponse(response); + } + + private void assertWellKnownResponse(WellKnownResponse response) { + String expectedRealmUrl = realmUrl(); + + assertNotNull(response); + assertEquals(expectedRealmUrl, response.policyDecisionPoint()); + assertEquals(expectedRealmUrl + "/authzen/access/v1/evaluation", response.accessEvaluationEndpoint()); + } + + private String realmUrl() { + return keycloakUrls.getBase() + "/realms/" + realm.getName(); + } +} diff --git a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json new file mode 100644 index 000000000000..ac5e7d440f4f --- /dev/null +++ b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json @@ -0,0 +1,304 @@ +{ + "enabled": true, + "roles": { + "realm": [ + { "name": "admin" }, + { "name": "editor" }, + { "name": "viewer" } + ] + }, + "users": [ + { + "id": "fd0614d3-c39a-4781-b7bd-8b96f5a5100d", + "username": "rick", + "enabled": true, + "firstName": "Rick", + "lastName": "Sanchez", + "email": "rick@the-citadel.com", + "credentials": [ + { "type": "password", "value": "password" } + ], + "realmRoles": ["admin"] + }, + { + "id": "fd1614d3-c39a-4781-b7bd-8b96f5a5100d", + "username": "morty", + "enabled": true, + "firstName": "Morty", + "lastName": "Smith", + "email": "morty@the-citadel.com", + "credentials": [ + { "type": "password", "value": "password" } + ], + "realmRoles": ["editor"] + }, + { + "id": "fd2614d3-c39a-4781-b7bd-8b96f5a5100d", + "username": "summer", + "enabled": true, + "firstName": "Summer", + "lastName": "Smith", + "email": "summer@the-smiths.com", + "credentials": [ + { "type": "password", "value": "password" } + ], + "realmRoles": ["editor"] + }, + { + "id": "fd3614d3-c39a-4781-b7bd-8b96f5a5100d", + "username": "beth", + "enabled": true, + "firstName": "Beth", + "lastName": "Smith", + "email": "beth@the-smiths.com", + "credentials": [ + { "type": "password", "value": "password" } + ], + "realmRoles": ["viewer"] + }, + { + "id": "fd4614d3-c39a-4781-b7bd-8b96f5a5100d", + "username": "jerry", + "enabled": true, + "firstName": "Jerry", + "lastName": "Smith", + "email": "jerry@the-smiths.com", + "credentials": [ + { "type": "password", "value": "password" } + ], + "realmRoles": ["viewer"] + } + ], + "clients": [ + { + "clientId": "authzen-pdp", + "enabled": true, + "secret": "authzen-pdp-secret", + "serviceAccountsEnabled": true, + "directAccessGrantsEnabled": false, + "authorizationServicesEnabled": true, + "authorizationSettings": { + "scopes": [ + { "name": "can_read_user" }, + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ], + "resources": [ + { + "name": "beth@the-smiths.com", + "type": "user", + "scopes": [{ "name": "can_read_user" }] + }, + { + "name": "rick@the-citadel.com", + "type": "user", + "scopes": [{ "name": "can_read_user" }] + }, + { + "name": "morty@the-citadel.com", + "type": "user", + "scopes": [{ "name": "can_read_user" }] + }, + { + "name": "summer@the-smiths.com", + "type": "user", + "scopes": [{ "name": "can_read_user" }] + }, + { + "name": "jerry@the-smiths.com", + "type": "user", + "scopes": [{ "name": "can_read_user" }] + }, + { + "name": "todo-1", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + }, + { + "name": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + }, + { + "name": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + }, + { + "name": "7240d0db-8ff0-41ec-98b2-34a096273b93", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + }, + { + "name": "7240d0db-8ff0-41ec-98b2-34a096273b94", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + }, + { + "name": "240d0db-8ff0-41ec-98b2-34a096273b95", + "type": "todo", + "scopes": [ + { "name": "can_read_todos" }, + { "name": "can_create_todo" }, + { "name": "can_update_todo" }, + { "name": "can_delete_todo" } + ] + } + ], + "policies": [ + { + "name": "admin-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"admin\"}]" + } + }, + { + "name": "editor-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"editor\"}]" + } + }, + { + "name": "viewer-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"viewer\"}]" + } + }, + { + "name": "owner-policy", + "type": "owner-policy", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS" + }, + { + "name": "any-role-policy", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "applyPolicies": "[\"admin-policy\",\"editor-policy\",\"viewer-policy\"]" + } + }, + { + "name": "admin-or-editor-policy", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "applyPolicies": "[\"admin-policy\",\"editor-policy\"]" + } + }, + { + "name": "editor-and-owner-policy", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"editor-policy\",\"owner-policy\"]" + } + }, + { + "name": "admin-or-editor-owner-policy", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "applyPolicies": "[\"admin-policy\",\"editor-and-owner-policy\"]" + } + }, + { + "name": "read-user-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"beth@the-smiths.com\",\"rick@the-citadel.com\",\"morty@the-citadel.com\",\"summer@the-smiths.com\",\"jerry@the-smiths.com\"]", + "scopes": "[\"can_read_user\"]", + "applyPolicies": "[\"any-role-policy\"]" + } + }, + { + "name": "read-todos-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "scopes": "[\"can_read_todos\"]", + "applyPolicies": "[\"any-role-policy\"]" + } + }, + { + "name": "create-todo-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "scopes": "[\"can_create_todo\"]", + "applyPolicies": "[\"admin-or-editor-policy\"]" + } + }, + { + "name": "update-todo-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "scopes": "[\"can_update_todo\"]", + "applyPolicies": "[\"admin-or-editor-owner-policy\"]" + } + }, + { + "name": "delete-todo-permission", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "scopes": "[\"can_delete_todo\"]", + "applyPolicies": "[\"admin-or-editor-owner-policy\"]" + } + } + ] + } + } + ] +} diff --git a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json new file mode 100644 index 000000000000..162952c0bab0 --- /dev/null +++ b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json @@ -0,0 +1,707 @@ +{ + "evaluation": [ + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "rick@the-citadel.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_todos" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_create_todo" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "properties": { + "ownerID": "morty@the-citadel.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "properties": { + "ownerID": "morty@the-citadel.com" + } + } + }, + "expected": true + }, + + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "morty@the-citadel.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_todos" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_create_todo" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "_note" : "ID for Morty", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "properties": { + "ownerID": "morty@the-citadel.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "properties": { + "ownerID": "morty@the-citadel.com" + } + } + }, + "expected": true + }, + + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "summer@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_todos" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_create_todo" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b93", + "properties": { + "ownerID": "summer@the-smiths.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b93", + "properties": { + "ownerID": "summer@the-smiths.com" + } + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_todos" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_create_todo" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b94", + "properties": { + "ownerID": "beth@the-smiths.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b94", + "properties": { + "ownerID": "beth@the-smiths.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "beth@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_user" + }, + "resource": { + "type": "user", + "id": "jerry@the-smiths.com" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_read_todos" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": true + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_create_todo" + }, + "resource": { + "type": "todo", + "id": "todo-1" + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "resource": { + "type": "todo", + "id": "240d0db-8ff0-41ec-98b2-34a096273b95", + "properties": { + "ownerID": "jerry@the-smiths.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + "expected": false + }, + { + "request": { + "subject": { + "type": "user", + "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_delete_todo" + }, + "resource": { + "type": "todo", + "id": "240d0db-8ff0-41ec-98b2-34a096273b95", + "properties": { + "ownerID": "jerry@the-smiths.com" + } + } + }, + "expected": false + } + ] +} diff --git a/authzen/tests/pom.xml b/authzen/tests/pom.xml new file mode 100644 index 000000000000..92f4bd486689 --- /dev/null +++ b/authzen/tests/pom.xml @@ -0,0 +1,34 @@ + + + + keycloak-tests-parent + org.keycloak.tests + 999.0.0-SNAPSHOT + ../../tests/pom.xml + + 4.0.0 + + keycloak-authzen-tests-parent + Keycloak AuthZen Base Testsuite Parent + pom + Keycloak AuthZen Base Testsuite Parent + + + + + org.keycloak.testframework + keycloak-test-framework-bom + ${project.version} + import + pom + + + + + + base + authzen-client + providers + + diff --git a/authzen/tests/providers/pom.xml b/authzen/tests/providers/pom.xml new file mode 100644 index 000000000000..0a6fcb56784c --- /dev/null +++ b/authzen/tests/providers/pom.xml @@ -0,0 +1,30 @@ + + + + keycloak-authzen-tests-parent + org.keycloak.tests + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-authzen-tests-providers + Keycloak AuthZen Test Providers + jar + + + + org.keycloak + keycloak-core + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + diff --git a/authzen/tests/providers/src/main/java/org/keycloak/tests/authzen/providers/OwnerPolicyProvider.java b/authzen/tests/providers/src/main/java/org/keycloak/tests/authzen/providers/OwnerPolicyProvider.java new file mode 100644 index 000000000000..475e3b1b5d74 --- /dev/null +++ b/authzen/tests/providers/src/main/java/org/keycloak/tests/authzen/providers/OwnerPolicyProvider.java @@ -0,0 +1,90 @@ +package org.keycloak.tests.authzen.providers; + +import org.keycloak.Config; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.attribute.Attributes; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.policy.evaluation.Evaluation; +import org.keycloak.authorization.policy.provider.PolicyProvider; +import org.keycloak.authorization.policy.provider.PolicyProviderAdminService; +import org.keycloak.authorization.policy.provider.PolicyProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; + +/** + * Grants access when the "ownerID" context attribute matches the requesting user's email. + * Used by the AuthZen interop tests. + */ +public class OwnerPolicyProvider implements PolicyProviderFactory, PolicyProvider { + + @Override + public String getName() { + return "Owner Policy"; + } + + @Override + public String getGroup() { + return "Test Suite"; + } + + @Override + public PolicyProvider create(AuthorizationProvider authorization) { + return this; + } + + @Override + public PolicyRepresentation toRepresentation(Policy policy, AuthorizationProvider authorization) { + return new PolicyRepresentation(); + } + + @Override + public Class getRepresentationType() { + return PolicyRepresentation.class; + } + + @Override + public PolicyProviderAdminService getAdminResource(ResourceServer resourceServer, AuthorizationProvider authorization) { + return null; + } + + @Override + public PolicyProvider create(KeycloakSession session) { + return null; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "owner-policy"; + } + + @Override + public void evaluate(Evaluation evaluation) { + Attributes contextAttributes = evaluation.getContext().getAttributes(); + Attributes identityAttributes = evaluation.getContext().getIdentity().getAttributes(); + + Attributes.Entry ownerID = contextAttributes.getValue("ownerID"); + Attributes.Entry userEmail = identityAttributes.getValue("email"); + + if (ownerID != null && userEmail != null) { + String owner = ownerID.asString(0); + String email = userEmail.asString(0); + if (owner.equals(email)) { + evaluation.grant(); + } + } + } +} diff --git a/authzen/tests/providers/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory b/authzen/tests/providers/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory new file mode 100644 index 000000000000..1136ee47730b --- /dev/null +++ b/authzen/tests/providers/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory @@ -0,0 +1 @@ +org.keycloak.tests.authzen.providers.OwnerPolicyProvider diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index d42a6145fcce..fc9dfe031db8 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -50,6 +50,8 @@ public class Profile { public enum Feature { AUTHORIZATION("Authorization Service", Type.DEFAULT), + AUTHZEN("OpenID AuthZen Authorization", Type.EXPERIMENTAL), + ACCOUNT_API("Account Management REST API", Type.DEFAULT), ACCOUNT_V3("Account Console version 3", Type.DEFAULT, 3, Feature.ACCOUNT_API), diff --git a/pom.xml b/pom.xml index e9840fa1f8c4..8fafb387bdff 100644 --- a/pom.xml +++ b/pom.xml @@ -314,6 +314,7 @@ tests quarkus scim + authzen @@ -1341,6 +1342,11 @@ keycloak-scim-services ${project.version} + + org.keycloak + keycloak-authzen-services + ${project.version} + org.keycloak keycloak-scim-model diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index f9a27887ccf6..d0e016d7b4c5 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -824,6 +824,7 @@ void persistBuildTimeProperties(BuildProducer resour void index(BuildProducer indexDependencyBuildItemBuildProducer) { indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.liquibase", "liquibase-core")); indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.keycloak", "keycloak-services")); + indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.keycloak", "keycloak-authzen-services")); indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("com.fasterxml.jackson.jakarta.rs", "jackson-jakarta-rs-yaml-provider")); } diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 428326440431..92d70bc6335d 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -443,6 +443,16 @@ + + org.keycloak + keycloak-authzen-services + + + * + * + + + org.keycloak keycloak-scim-core diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java new file mode 100644 index 000000000000..f928a704a50d --- /dev/null +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.it.cli.dist; + +import org.keycloak.common.Profile; +import org.keycloak.it.junit5.extension.CLIResult; +import org.keycloak.it.junit5.extension.DistributionTest; +import org.keycloak.quarkus.runtime.cli.command.Start; +import org.keycloak.quarkus.runtime.cli.command.StartDev; + +import io.quarkus.test.junit.main.Launch; +import org.junit.jupiter.api.Test; + +@DistributionTest +public class AuthZenDistTest { + + private static final String AUTHZEN_HTTPS_ERROR = "The AuthZen feature requires HTTPS in order to be compliant with the OpenID AuthZen specification."; + + @Test + @Launch({Start.NAME, "--db=dev-file", "--features=authzen", "--http-enabled=true", "--hostname-strict=false"}) + public void testHttpEnabledErrorInProductionMode(CLIResult cliResult) { + cliResult.assertError(AUTHZEN_HTTPS_ERROR); + } + + @Test + @Launch({StartDev.NAME, "--features=authzen"}) + public void testNoHttpEnabledWarningInDevMode(CLIResult cliResult) { + cliResult.assertStartedDevMode(); + cliResult.assertMessage(Profile.Feature.AUTHZEN.getVersionedKey()); + cliResult.assertNoError(AUTHZEN_HTTPS_ERROR); + } +} From c404dabecc2a4790138a004e9cfc0f20435c3772 Mon Sep 17 00:00:00 2001 From: Ryan Emerson Date: Thu, 16 Apr 2026 13:40:47 +0100 Subject: [PATCH 2/3] Remove http enabled check to cater for TLS terminating proxies Signed-off-by: Ryan Emerson --- .../AuthZenRealmResourceProviderFactory.java | 4 -- .../keycloak/it/cli/dist/AuthZenDistTest.java | 46 ------------------- 2 files changed, 50 deletions(-) delete mode 100644 quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java index d1095722fc79..80a27dc5345e 100644 --- a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenRealmResourceProviderFactory.java @@ -19,7 +19,6 @@ import org.keycloak.Config; import org.keycloak.common.Profile; import org.keycloak.common.Profile.Feature; -import org.keycloak.common.util.Environment; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.EnvironmentDependentProviderFactory; @@ -37,9 +36,6 @@ public RealmResourceProvider create(KeycloakSession session) { @Override public void init(Config.Scope config) { - if (!Environment.isDevMode() && config.root().getBoolean("http-enabled", false)) { - throw new IllegalArgumentException("The AuthZen feature requires HTTPS in order to be compliant with the OpenID AuthZen specification."); - } } @Override diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java deleted file mode 100644 index f928a704a50d..000000000000 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/AuthZenDistTest.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2025 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.keycloak.it.cli.dist; - -import org.keycloak.common.Profile; -import org.keycloak.it.junit5.extension.CLIResult; -import org.keycloak.it.junit5.extension.DistributionTest; -import org.keycloak.quarkus.runtime.cli.command.Start; -import org.keycloak.quarkus.runtime.cli.command.StartDev; - -import io.quarkus.test.junit.main.Launch; -import org.junit.jupiter.api.Test; - -@DistributionTest -public class AuthZenDistTest { - - private static final String AUTHZEN_HTTPS_ERROR = "The AuthZen feature requires HTTPS in order to be compliant with the OpenID AuthZen specification."; - - @Test - @Launch({Start.NAME, "--db=dev-file", "--features=authzen", "--http-enabled=true", "--hostname-strict=false"}) - public void testHttpEnabledErrorInProductionMode(CLIResult cliResult) { - cliResult.assertError(AUTHZEN_HTTPS_ERROR); - } - - @Test - @Launch({StartDev.NAME, "--features=authzen"}) - public void testNoHttpEnabledWarningInDevMode(CLIResult cliResult) { - cliResult.assertStartedDevMode(); - cliResult.assertMessage(Profile.Feature.AUTHZEN.getVersionedKey()); - cliResult.assertNoError(AUTHZEN_HTTPS_ERROR); - } -} From c380d4461891b0417a1c77b22d3c3acffee3998b Mon Sep 17 00:00:00 2001 From: Ryan Emerson Date: Thu, 16 Apr 2026 14:32:09 +0100 Subject: [PATCH 3/3] Revert KeycloakProcessor changes and add empty beans.xml Signed-off-by: Ryan Emerson --- authzen/services/src/main/resources/META-INF/beans.xml | 0 .../java/org/keycloak/quarkus/deployment/KeycloakProcessor.java | 1 - 2 files changed, 1 deletion(-) create mode 100644 authzen/services/src/main/resources/META-INF/beans.xml diff --git a/authzen/services/src/main/resources/META-INF/beans.xml b/authzen/services/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index d0e016d7b4c5..f9a27887ccf6 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -824,7 +824,6 @@ void persistBuildTimeProperties(BuildProducer resour void index(BuildProducer indexDependencyBuildItemBuildProducer) { indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.liquibase", "liquibase-core")); indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.keycloak", "keycloak-services")); - indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.keycloak", "keycloak-authzen-services")); indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("com.fasterxml.jackson.jakarta.rs", "jackson-jakarta-rs-yaml-provider")); }