8000 Limit AuthZEN queries to tokens with the authzen scope and audience by kanywst · Pull Request #55111 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

public final class AuthZen {

public static final String SCOPE = "authzen";
public static final String AUTHZEN_ACCESS_PATH = "access/v1";
public static final String EVALUATION_PATH = AUTHZEN_ACCESS_PATH + "/evaluation";
public static final String EVALUATIONS_PATH = AUTHZEN_ACCESS_PATH + "/evaluations";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;

public class AuthZenResource {

Expand Down Expand Up @@ -131,7 +132,9 @@ private static AuthZen.EvaluationRequest mergeDefaults(AuthZen.EvaluationsReques
}

private AccessToken authenticateClient() {
AuthResult authResult = new AppAuthManager.BearerTokenAuthenticator(session).authenticate();
AuthResult authResult = new AppAuthManager.BearerTokenAuthenticator(session)
.setAudience(AuthZenWellKnownProvider.audience(session))
.authenticate();
if (authResult == null) {
throw new NotAuthorizedException("Bearer");
}
Expand All @@ -140,6 +143,9 @@ private AccessToken authenticateClient() {
if (!authResult.client().getId().equals(authResult.user().getServiceAccountClientLink()) || !Objects.equals(authResult.session().getAuthMethod(), ServiceAccountConstants.CLIENT_AUTH)) {
throw new NotAuthorizedException("Bearer");
}
if (!TokenUtil.hasScope(authResult.token().getScope(), AuthZen.SCOPE)) {
throw new NotAuthorizedException("Bearer");
}
return authResult.token();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ public AuthZenWellKnownProvider(KeycloakSession session) {

@Override
public Object getConfig() {
RealmModel realm = session.getContext().getRealm();
String realmUri = Urls.realmIssuer(session.getContext().getUri(UrlType.FRONTEND).getBaseUri(), realm.getName());
String realmUri = realmUri(session);

return Map.of(
"policy_decision_point", realmUri,
Expand All @@ -44,6 +43,17 @@ public Object getConfig() {
);
}

/**
* The audience that access tokens must contain to call the AuthZEN endpoints of the current realm.
*/
public static String audience(KeycloakSession session) {
return audience(realmUri(session));
}

public static String audience(String realmUri) {
return String.format("%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID);
}

public static String accessEvaluationEndpoint(String realmUri) {
return String.format("%s/%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID, AuthZen.EVALUATION_PATH);
}
Expand All @@ -52,6 +62,11 @@ public static String accessEvaluationsEndpoint(String realmUri) {
return String.format("%s/%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID, AuthZen.EVALUATIONS_PATH);
}

private static String realmUri(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
return Urls.realmIssuer(session.getContext().getUri(UrlType.FRONTEND).getBaseUri(), realm.getName());
}

@Override
public void close() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.keycloak.authorization.authzen.AuthZen;
import org.keycloak.testframework.annotations.InjectRealm;
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.AuthZenClient.EvaluationsResult;
Expand Down Expand Up @@ -72,6 +73,11 @@ public class AuthZenEvaluationInteropTest {
@InjectAuthZenClient
AuthZenClient authZenClient;

@TestSetup
public void setup() {
AuthZenScope.create(realm, PDP_CLIENT_ID);
}

@TestFactory
Stream<DynamicTest> interopEvaluationTests() throws IOException {
JsonNode root = loadDecisions();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
import jakarta.ws.rs.core.Response;

import org.keycloak.admin.client.resource.AuthorizationResource;
import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpResponse;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.PolicyRepresentation;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
Expand Down Expand Up @@ -58,10 +61,12 @@
import org.apache.http.entity.StringEntity;
import org.junit.jupiter.api.Test;

import static org.keycloak.authorization.authzen.AuthZen.SCOPE;
import static org.keycloak.authorization.authzen.AuthZen.SubjectType.CLIENT;
import static org.keycloak.authorization.authzen.AuthZen.SubjectType.USER;
import static org.keycloak.authorization.authzen.AuthZenRequestIdFilter.X_REQUEST_ID;
import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.accessEvaluationEndpoint;
import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.audience;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
Expand Down Expand Up @@ -89,6 +94,9 @@ public class AuthZenEvaluationTest {
@InjectClient(ref = "disableable-client", config = AuthzClientConfig.class)
ManagedClient disableableClient;

@InjectClient(ref = "no-scope-client", config = AuthzClientConfig.class)
ManagedClient noScopeClient;

@InjectOAuthClient
OAuthClient oauth;

Expand All @@ -103,6 +111,12 @@ public class AuthZenEvaluationTest {

@TestSetup
public void setup() {
AuthZenScope.create(realm, client.getClientId(), noAuthzClient.getClientId(), subjectClient.getClientId(),
disableableClient.getClientId());
try (Response response = noScopeClient.admin().getProtocolMappers()
.createMapper(AuthZenScope.audienceMapper(audience(realm.getBaseUrl())))) {
assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
}
configureAuthorizationResources();
}

Expand Down Expand Up @@ -438,6 +452,48 @@ public void testPasswordGrantTokenReturnsUnauthorized() throws IOException {
assertEquals(401, result.statusCode());
}

@Test
public void testTokenWithoutAuthZenScopeReturnsUnauthorized() throws IOException {
AccessTokenResponse tokenResponse = oauth
.client(noScopeClient.getClientId(), noScopeClient.getSecret())
.doClientCredentialsGrantAccessTokenRequest();

EvaluationResult result = authZenClient.withAccessToken(tokenResponse.getAccessToken())
.evaluate(AuthZenClient.evaluationRequest()
.subject(USER, ADMIN_USER)
.action("read")
.resource("endpoint", "/admin")
.build());

assertEquals(401, result.statusCode());
}

@Test
public void testTokenWithoutAuthZenAudienceReturnsUnauthorized() throws IOException {
ClientScopeRepresentation scope = realm.admin().clientScopes().findAll().stream()
.filter(s -> SCOPE.equals(s.getName()))
.findFirst()
.orElseThrow();
ProtocolMappersResource mappers = realm.admin().clientScopes().get(scope.getId()).getProtocolMappers();
ProtocolMapperRepresentation original = mappers.getMappers().stream()
.filter(m -> AuthZenScope.AUDIENCE_MAPPER.equals(m.getName()))
.findFirst()
.orElseThrow();
ProtocolMapperRepresentation updated = AuthZenScope.audienceMapper("https://pdp.example.org");
updated.setId(original.getId());
mappers.update(original.getId(), updated);
realm.cleanup().add(r -> r.clientScopes().get(scope.getId()).getProtocolMappers().update(original.getId(), original));

EvaluationResult result = authzenClient()
.evaluate(AuthZenClient.evaluationRequest()
.subject(USER, ADMIN_USER)
.action("read")
.resource("endpoint", "/admin")
.build());

assertEquals(401, result.statusCode());
}

@Test
public void testMissingSubjectReturnsBadRequest() throws IOException {
EvaluationResult result = authzenClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
import static org.keycloak.authorization.authzen.AuthZen.SubjectType.USER;
import static org.keycloak.authorization.authzen.AuthZenRequestIdFilter.X_REQUEST_ID;
import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.accessEvaluationsEndpoint;
import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.audience;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
Expand All @@ -81,6 +82,9 @@ public class AuthZenEvaluationsTest {
@InjectClient(ref = "authzen-client", config = AuthzClientConfig.class)
ManagedClient client;

@InjectClient(ref = "no-scope-client", config = AuthzClientConfig.class)
ManagedClient noScopeClient;

@InjectOAuthClient
OAuthClient oauth;

Expand All @@ -95,6 +99,12 @@ public class AuthZenEvaluationsTest {

@TestSetup
public void setup() {
AuthZenScope.create(realm, client.getClientId());
try (Response response = noScopeClient.admin().getProtocolMappers()
.createMapper(AuthZenScope.audienceMapper(audience(realm.getBaseUrl())))) {
assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
}

AuthorizationResource authz = client.admin().authorization();
String adminRoleId = realm.admin().roles().get("admin").toRepresentation().getId();

Expand Down Expand Up @@ -206,6 +216,24 @@ public void testPasswordGrantTokenReturnsUnauthorized() throws IOException {
assertEquals(401, result.statusCode());
}

@Test
public void testTokenWithoutAuthZenScopeReturnsUnauthorized() throws IOException {
AccessTokenResponse tokenResponse = oauth
.client(noScopeClient.getClientId(), noScopeClient.getSecret())
.doClientCredentialsGrantAccessTokenRequest();

EvaluationsResult result = authZenClient.withAccessToken(tokenResponse.getAccessToken())
.evaluations(AuthZenClient.evaluationsRequest()
.subject(USER, ADMIN_USER)
.action("read")
.addEvaluation(AuthZenClient.evaluationItem()
.resource("endpoint", "/admin")
.build())
.build());

assertEquals(401, result.statusCode());
}

@Test
public void testEvaluationsEmptyArrayFallsBackToSingleEvaluation() throws IOException {
String json = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2026 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.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.testframework.realm.ClientScopeBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.ProtocolMapperBuilder;
import org.keycloak.testframework.util.ApiUtil;

import static org.keycloak.authorization.authzen.AuthZen.SCOPE;
import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.audience;

/**
* Creates the "authzen" client scope as described in the AuthZEN guide.
*/
final class AuthZenScope {

static final String AUDIENCE_MAPPER = "authzen-audience";

private AuthZenScope() {
}

static String create(ManagedRealm realm, String... clientIds) {
ClientScopeRepresentation scope = ClientScopeBuilder.create()
.name(SCOPE)
.protocol("openid-connect")
.mappers(audienceMapper(audience(realm.getBaseUrl())))
.build();

RealmResource admin = realm.admin();
String scopeId = ApiUtil.getCreatedId(admin.clientScopes().create(scope));

for (String clientId : clientIds) {
ClientResource client = admin.clients().get(admin.clients().findByClientId(clientId).get(0).getId());
client.addDefaultClientScope(scopeId);
}
return scopeId;
}

static ProtocolMapperRepresentation audienceMapper(String audience) {
return ProtocolMapperBuilder.create()
.name(AUDIENCE_MAPPER)
.protocol("openid-connect")
.protocolMapper("oidc-audience-mapper")
.config("included.custom.audience", audience)
.config("access.token.claim", "true")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,13 @@ When upgrading, existing `aes-generated` key providers without an explicit `secr
This ensures the Admin Console displays the actual key size and saving a provider without changes preserves its key and key ID.
Providers with an explicitly configured size are preserved.

=== AuthZEN requires the `authzen` client scope and audience
// https://github.com/keycloak/keycloak/issues/50527

When calling {project_name}'s experimental AuthZEN endpoints, the access token now needs to contain the `authzen` scope and the AuthZEN audience, which is the realm URL followed by `/authzen`.
Previously, a token from any confidential client with authorization services enabled was accepted.
Create an `authzen` client scope with an audience mapper and assign it to the clients that query the PDP, as described in the link:{securing_apps_base_link}/authzen-authorization[AuthZEN Authorization] guide.

=== SAML assertions with a OneTimeUse condition are no longer replayable
// https://github.com/keycloak/keycloak/issues/46325

Expand Down
30 changes: 26 additions & 4 deletions docs/guides/securing-apps/authzen-authorization.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ bin/kc.[sh|bat] start --features=authzen
AuthZEN exposes {project_name}'s existing authorization services. Before using AuthZEN, you must configure the following in your realm:

. link:{authorizationguide_link}#_resource_server_enable_authorization[A client with authorization enabled]-- The client whose access token is used to authenticate AuthZEN requests must have *Authorization Enabled* in its settings.
. <<authzen-client-scope,The `authzen` client scope>> -- The client must have the `authzen` client scope assigned.
. link:{authorizationguide_link}#_resource_create[Resources] -- Define resources in the client's *Authorization* -> *Resources* section. The resource name maps to the `resource.id` in AuthZEN requests, and the resource type maps to `resource.type`.
. link:{authorizationguide_link}#_resource_create[Scopes] -- Define scopes in the client's *Authorization* -> *Scopes* section. Scope names map to the `action.name` in AuthZEN requests.
. link:{authorizationguide_link}#_policy_overview[Policies] and link:{authorizationguide_link}#_permissions_overview[Permissions] -- Create policies and permissions that associate resources and scopes with authorization decisions.
Expand All @@ -54,8 +55,11 @@ For example, a realm called `myrealm` will return the following metadata:

== Authentication

All AuthZEN endpoints require a valid bearer token in the `Authorization` header and the token must be issued for a
client that has authorization services enabled.
All AuthZEN endpoints require a valid bearer token in the `Authorization` header.
The token must be obtained with the client credentials grant by a client that has authorization services enabled, and it must contain:

* `authzen` in its `scope` claim.
* The AuthZEN audience in its `aud` claim. The audience is the realm URL followed by `/authzen`, for example `https://keycloak.example.com/realms/myrealm/authzen`.

[source,bash]
----
Expand All @@ -64,7 +68,24 @@ Authorization: Bearer <access-token>
Content-Type: application/json
----

If no valid token is provided, the server returns a `401 Unauthorized` response.
If no valid token is provided, or the token does not meet these requirements, the server returns a `401 Unauthorized` response.

[[authzen-client-scope]]
=== Adding the `authzen` client scope

{project_name} does not create the `authzen` client scope.
Create it once per realm and assign it to each client that queries the PDP:

. Click *Client scopes* in the menu and click *Create client scope*.
. Enter `authzen` as the *Name*, make sure *Include in token scope* is on, and click *Save*.
. In the *Mappers* tab, click *Configure a new mapper* and select *Audience*.
. Enter `authzen-audience` as the *Name*, leave *Included Client Audience* empty, and enter the AuthZEN audience as the *Included Custom Audience*, for example `https://keycloak.example.com/realms/myrealm/authzen`.
Make sure *Add to access token* is on and click *Save*.
. Click *Clients* in the menu and select the client that queries the PDP.
. In the *Client scopes* tab, click *Add client scope*, select `authzen`, and add it as *Default*.

The audience is derived from the frontend URL of {project_name}.
Configure a stable hostname so that the audience in the token matches the URL of the AuthZEN endpoints.

== Evaluation API

Expand Down Expand Up @@ -411,7 +432,8 @@ AuthZEN endpoints return standard HTTP error codes for invalid requests:
value, uses an empty namespace prefix (e.g., `id:` with no value), or contains malformed JSON.

|`401 Unauthorized`
|The request does not include a valid bearer token in the `Authorization` header.
|The request does not include a valid bearer token in the `Authorization` header, or the token does not contain the
`authzen` scope and the AuthZEN audience.
|===

== Request tracing
Expand Down
0