DPoP: User Info Endpoint authorization type mismatch

closes #36476

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2025-03-19 20:22:23 +09:00 committed by GitHub
parent 1d9c0f373a
commit be818502ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 95 additions and 3 deletions

View File

@ -80,6 +80,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Map;
import java.util.regex.Pattern;
/**
* @author pedroigor
@ -99,6 +100,8 @@ public class UserInfoEndpoint {
private Cors cors;
private TokenForUserInfo tokenForUserInfo = new TokenForUserInfo();
private static final Pattern WHITESPACES = Pattern.compile("\\s+");
public UserInfoEndpoint(KeycloakSession session, org.keycloak.protocol.oidc.TokenManager tokenManager) {
this.session = session;
this.clientConnection = session.getContext().getConnection();
@ -257,6 +260,16 @@ public class UserInfoEndpoint {
}
if (Profile.isFeatureEnabled(Profile.Feature.DPOP)) {
String authHeader = request.getHttpHeaders().getHeaderString(HttpHeaders.AUTHORIZATION);
String[] split = WHITESPACES.split(authHeader.trim());
String bearerPart = split[0];
if (!bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP) && DPoPUtil.DPOP_TOKEN_TYPE.equals(token.getType())) {
String errorMessage = "The access token type is DPoP but Authorization Header is not DPoP";
event.detail(Details.REASON, errorMessage);
event.error(Errors.NOT_ALLOWED);
throw error.invalidToken(errorMessage);
}
if (OIDCAdvancedConfigWrapper.fromClientModel(clientModel).isUseDPoP() || DPoPUtil.DPOP_TOKEN_TYPE.equals(token.getType())) {
try {
DPoP dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate();
@ -268,6 +281,7 @@ public class UserInfoEndpoint {
throw error.invalidToken(errorMessage);
}
}
}
// Existence of authenticatedClientSession for our client already handled before

View File

@ -19,6 +19,7 @@ package org.keycloak.services.managers;
import jakarta.ws.rs.NotAuthorizedException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
@ -28,6 +29,7 @@ import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.util.TokenUtil;
import java.util.List;
import java.util.regex.Pattern;
/**
@ -67,8 +69,15 @@ public class AppAuthManager extends AuthenticationManager {
}
String bearerPart = split[0];
if (!bearerPart.equalsIgnoreCase(BEARER) && !bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP)){
return null;
if (!Profile.isFeatureEnabled(Profile.Feature.DPOP)) {
if (bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP)){
return null;
}
} else {
if (!bearerPart.equalsIgnoreCase(BEARER) && !bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP)){
return null;
}
}
String tokenString = split[1];
@ -86,6 +95,14 @@ public class AppAuthManager extends AuthenticationManager {
* @return the token string or {@literal null} if the Authorization header is not of type Bearer, or the token string is missing.
*/
public static String extractAuthorizationHeaderTokenOrReturnNull(HttpHeaders headers) {
// error if including more than one Authorization header
List<String> authHeaders = headers.getRequestHeaders().get(HttpHeaders.AUTHORIZATION);
if (authHeaders == null || authHeaders.isEmpty()) {
return null;
}
if (authHeaders.size() != 1) {
throw new NotAuthorizedException(BEARER);
}
String authHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION);
return extractTokenStringFromAuthHeader(authHeader);
}

View File

@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
@ -88,9 +89,11 @@ import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.UserManager;
import org.keycloak.testsuite.util.oauth.UserInfoResponse;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.keycloak.utils.MediaType;
import org.openqa.selenium.By;
import jakarta.ws.rs.client.Client;
@ -136,6 +139,7 @@ public class AccessTokenTest extends AbstractKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
private HttpGet get;
@Override
public void beforeAbstractKeycloakTest() throws Exception {
@ -430,6 +434,23 @@ public class AccessTokenTest extends AbstractKeycloakTest {
RealmManager.realm(adminClient.realm("test")).accessCodeLifeSpan(60);
}
@Test
public void bearerAccessTokenAsDPoPOnUserInfoEndpoint() throws IOException {
oauth.doLogin("test-user@localhost", "password");
EventRepresentation loginEvent = events.expectLogin().assertEvent();
loginEvent.getSessionId();
AccessTokenResponse response = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode());
Assert.assertEquals(200, response.getStatusCode());
get = new HttpGet(oauth.getEndpoints().getUserInfo());
get.addHeader("Accept", MediaType.APPLICATION_JSON);
get.addHeader(HttpHeaders.AUTHORIZATION, "DPoP" + " " + response.getAccessToken());
UserInfoResponse userInfoResponse = new UserInfoResponse(oauth.httpClient().get().execute(get));
assertEquals(401, userInfoResponse.getStatusCode());
}
@Test
public void accessTokenCodeUsed() throws IOException {
oauth.doLogin("test-user@localhost", "password");

View File

@ -19,8 +19,10 @@ package org.keycloak.testsuite.oauth;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.apache.http.client.methods.HttpGet;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -71,6 +73,7 @@ import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.keycloak.utils.MediaType;
import java.io.IOException;
import java.security.KeyPair;
@ -85,7 +88,6 @@ import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
@ -122,6 +124,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
private String jktEc;
private ClientRegistration reg;
private HttpGet get;
@Before
public void beforeDPoPTest() throws Exception {
rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
@ -149,6 +153,42 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void testDuplicatedAuthorizationHeaderOnUserInfo() throws Exception {
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
get = new HttpGet(oauth.getEndpoints().getUserInfo());
get.addHeader("Accept", MediaType.APPLICATION_JSON);
String authorization = "DPoP" + " " + response.getAccessToken();
get.addHeader(HttpHeaders.AUTHORIZATION, authorization);
get.addHeader(HttpHeaders.AUTHORIZATION, authorization);
UserInfoResponse userInfoResponse = new UserInfoResponse(oauth.httpClient().get().execute(get));
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("HTTP 401 Unauthorized", userInfoResponse.getError());
oauth.doLogout(response.getRefreshToken());
}
@Test
public void testDPoPAccessTokenButBearerAuthorizationHeader() throws Exception {
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
get = new HttpGet(oauth.getEndpoints().getUserInfo());
get.addHeader("Accept", MediaType.APPLICATION_JSON);
String authorization = "Bearer" + " " + response.getAccessToken();
get.addHeader(HttpHeaders.AUTHORIZATION, authorization);
UserInfoResponse userInfoResponse = new UserInfoResponse(oauth.httpClient().get().execute(get));
assertEquals(401, userInfoResponse.getStatusCode());
oauth.doLogout(response.getRefreshToken());
}
@Test
public void testDPoPByPublicClientWithDpopJkt() throws Exception {
// use pre-computed EC key