Incorrect scheme in the WWW-Authenticate when Authorization: DPoP used

closes #42706

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2025-09-17 21:05:19 +02:00
committed by Marek Posolda
parent 37a99154a5
commit f5c71e3e55
6 changed files with 101 additions and 25 deletions

View File

@@ -168,6 +168,7 @@ public interface OAuth2Constants {
String DPOP_NONCE_HEADER = "DPoP-Nonce";
Algorithm DPOP_DEFAULT_ALGORITHM = PS256;
String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
String ALGS_ATTRIBUTE = "algs";
}

View File

@@ -103,7 +103,9 @@ public class UserInfoEndpoint {
this.realm = session.getContext().getRealm();
this.tokenManager = tokenManager;
this.appAuthManager = new AppAuthManager();
this.error = new OAuth2Error().json(false).realm(realm);
this.error = new OAuth2Error().json(false)
.session(session)
.realm(realm);
this.request = session.getContext().getHttpRequest();
}
@@ -119,7 +121,7 @@ public class UserInfoEndpoint {
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT})
public Response issueUserInfoGet() {
setupCors();
String accessToken = this.appAuthManager.extractAuthorizationHeaderTokenOrReturnNull(session.getContext().getRequestHeaders());
AppAuthManager.AuthHeader accessToken = AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(session.getContext().getRequestHeaders());
authorization(accessToken);
return issueUserInfo();
}
@@ -133,8 +135,8 @@ public class UserInfoEndpoint {
// Try header first
HttpHeaders headers = request.getHttpHeaders();
String accessToken = this.appAuthManager.extractAuthorizationHeaderTokenOrReturnNull(headers);
authorization(accessToken);
AppAuthManager.AuthHeader authHeader = AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(headers);
authorization(authHeader);
try {
@@ -144,8 +146,9 @@ public class UserInfoEndpoint {
if (jakarta.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED_TYPE.isCompatible(mediaType)) {
MultivaluedMap<String, String> formParams = request.getDecodedFormParameters();
checkAccessTokenDuplicated(formParams);
accessToken = formParams.getFirst(OAuth2Constants.ACCESS_TOKEN);
authorization(accessToken);
String accessToken = formParams.getFirst(OAuth2Constants.ACCESS_TOKEN);
authHeader = accessToken == null ? null : new AppAuthManager.AuthHeader(AppAuthManager.BEARER, accessToken);
authorization(authHeader);
}
} catch (IllegalArgumentException e) {
// not application/x-www-form-urlencoded, ignore
@@ -364,10 +367,11 @@ public class UserInfoEndpoint {
error.cors(cors);
}
private void authorization(String accessToken) {
if (accessToken != null) {
private void authorization(AppAuthManager.AuthHeader authHeader) {
if (authHeader != null) {
if (tokenForUserInfo.getToken() == null) {
tokenForUserInfo.setToken(accessToken);
error.authScheme(authHeader.getScheme());
tokenForUserInfo.setToken(authHeader.getToken());
} else {
throw error.cors(cors.allowAllOrigins()).invalidRequest("More than one method used for including an access token");
}

View File

@@ -204,7 +204,8 @@ public class BackchannelAuthenticationCallbackEndpoint extends AbstractCibaEndpo
* @return The raw bearer token.
*/
protected String getRawBearerToken(HttpHeaders httpHeaders, AuthenticationChannelResponse response) {
return AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(httpHeaders);
AppAuthManager.AuthHeader authHeader = AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(httpHeaders);
return authHeader == null ? null : authHeader.getToken();
}
protected void sendClientNotificationRequest(ClientModel client, CibaConfig cibaConfig, OAuth2DeviceCodeModel deviceModel) {

View File

@@ -40,7 +40,7 @@ import java.util.regex.Pattern;
*/
public class AppAuthManager extends AuthenticationManager {
private static final String BEARER = "Bearer";
public static final String BEARER = "Bearer";
private static final Pattern WHITESPACES = Pattern.compile("\\s+");
@@ -57,9 +57,9 @@ public class AppAuthManager extends AuthenticationManager {
/**
* Extracts the token string from the given Authorization Bearer header.
*
* @return the token string or {@literal null}
* @return authHeader with the token and scheme or {@literal null}
*/
private static String extractTokenStringFromAuthHeader(String authHeader) {
private static AuthHeader extractTokenStringFromAuthHeader(String authHeader) {
if (authHeader == null) {
return null;
@@ -88,16 +88,16 @@ public class AppAuthManager extends AuthenticationManager {
return null;
}
return tokenString;
return new AuthHeader(typeString, tokenString);
}
/**
* Extracts the token string from the Authorization Bearer Header.
*
* @param headers
* @return the token string or {@literal null} if the Authorization header is not of type Bearer, or the token string is missing.
* @return the authHeader with the token and scheme or {@literal null} if the Authorization header is not of supported type (EG. Bearer or DPoP), or the token string is missing.
*/
public static String extractAuthorizationHeaderTokenOrReturnNull(HttpHeaders headers) {
public static AuthHeader extractAuthorizationHeaderTokenOrReturnNull(HttpHeaders headers) {
// error if including more than one Authorization header
List<String> authHeaders = headers.getRequestHeaders().get(HttpHeaders.AUTHORIZATION);
if (authHeaders == null || authHeaders.isEmpty()) {
@@ -122,11 +122,11 @@ public class AppAuthManager extends AuthenticationManager {
if (authHeader == null) {
return null;
}
String tokenString = extractTokenStringFromAuthHeader(authHeader);
if (tokenString == null ){
AuthHeader parsedHeader = extractTokenStringFromAuthHeader(authHeader);
if (parsedHeader == null ){
throw new NotAuthorizedException(BEARER);
}
return tokenString;
return parsedHeader.getToken();
}
public static class BearerTokenAuthenticator {
@@ -198,4 +198,23 @@ public class AppAuthManager extends AuthenticationManager {
}
}
public static class AuthHeader {
private final String scheme;
private final String token;
public AuthHeader(String scheme, String token) {
this.scheme = scheme;
this.token = token;
}
public String getScheme() {
return scheme;
}
public String getToken() {
return token;
}
}
}

View File

@@ -36,11 +36,15 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.util.DPoPUtil;
import static jakarta.ws.rs.core.HttpHeaders.WWW_AUTHENTICATE;
import static org.keycloak.OAuth2Constants.ALGS_ATTRIBUTE;
import static org.keycloak.services.util.DPoPUtil.DPOP_SCHEME;
/**
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
@@ -49,7 +53,9 @@ public class OAuth2Error {
private static final Map<Response.Status, Class<? extends WebApplicationException>> STATUS_MAP = new HashMap<>();
private KeycloakSession session;
private RealmModel realm;
private String authScheme;
private String error;
private String errorDescription;
private Optional<Cors> cors = Optional.empty();
@@ -65,11 +71,21 @@ public class OAuth2Error {
STATUS_MAP.put(Response.Status.INTERNAL_SERVER_ERROR, InternalServerErrorException.class);
}
public OAuth2Error session(KeycloakSession session) {
this.session = session;
return this;
}
public OAuth2Error realm(RealmModel realm) {
this.realm = realm;
return this;
}
public OAuth2Error authScheme(String authScheme) {
this.authScheme = authScheme;
return this;
}
public OAuth2Error error(String error) {
this.error = error;
@@ -130,7 +146,7 @@ public class OAuth2Error {
OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
builder.entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE);
} else {
WWWAuthenticate.BearerChallenge bearer = new WWWAuthenticate.BearerChallenge();
WWWAuthenticate.BearerChallenge bearer = DPOP_SCHEME.equals(this.authScheme) ? new WWWAuthenticate.DPoPChallenge(session) : new WWWAuthenticate.BearerChallenge();
bearer.setRealm(realm.getName());
bearer.setError(error);
bearer.setErrorDescription(errorDescription);
@@ -301,6 +317,22 @@ public class OAuth2Error {
}
public static class DPoPChallenge extends BearerChallenge {
public DPoPChallenge(KeycloakSession session) {
List<String> dpopAlgs = DPoPUtil.getDPoPSupportedAlgorithms(session);
dpopAlgs.stream()
.reduce((str1, current) -> str1 + " " + current)
.ifPresent(algs -> setAttribute(ALGS_ATTRIBUTE, algs));
}
@Override
public String getScheme() {
return DPOP_SCHEME;
}
}
}
}

View File

@@ -106,6 +106,7 @@ import java.util.stream.Collectors;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
@@ -113,6 +114,8 @@ import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.OAuth2Constants.DPOP_HTTP_HEADER;
import static org.keycloak.OAuth2Constants.DPOP_JWT_HEADER_TYPE;
import static org.keycloak.OAuthErrorException.INVALID_TOKEN;
import static org.keycloak.services.util.DPoPUtil.DPOP_SCHEME;
import static org.keycloak.services.util.DPoPUtil.DPOP_TOKEN_TYPE;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig;
@@ -546,7 +549,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).dpop(null).send();
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"Token verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
testWWWAuthenticateHeaderError(userInfoResponse);
oauth.doLogout(response.getRefreshToken());
} finally {
@@ -561,7 +564,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).dpop(null).send();
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"Token verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
testWWWAuthenticateHeaderError(userInfoResponse);
oauth.doLogout(response.getRefreshToken());
}
@@ -577,7 +580,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET, oauth.getEndpoints().getToken(), (long) Time.currentTime(), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate(), response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).dpop(dpopProofRsaEncoded).send();
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"Token verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
testWWWAuthenticateHeaderError(userInfoResponse);
oauth.doLogout(response.getRefreshToken());
}
@@ -591,7 +594,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// use the same DPoP proof
UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).dpop(dpopProof).send();
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"Token verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
testWWWAuthenticateHeaderError(userInfoResponse);
oauth.doLogout(response.getRefreshToken());
}
@@ -608,11 +611,27 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET, oauth.getEndpoints().getUserInfo(), (long) Time.currentTime(), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate(), response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.userInfoRequest(response.getAccessToken()).dpop(dpopProofRsaEncoded).send();
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"Token verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
testWWWAuthenticateHeaderError(userInfoResponse);
oauth.doLogout(response.getRefreshToken());
}
private void testWWWAuthenticateHeaderError(UserInfoResponse userInfoResponse) {
String wwwAuthenticate = userInfoResponse.getHeaders().get("WWW-Authenticate");
Assert.assertThat(wwwAuthenticate, startsWith(DPOP_SCHEME));
String chunks1 = wwwAuthenticate.substring(DPOP_SCHEME.length() + 1);
Map<String, String> map = new HashMap<>();
for (String p : chunks1.split(", ")) {
String[] chunks2 = p.split("=");
map.put(chunks2[0], chunks2[1]);
}
Assert.assertEquals(map.get(OAuth2Constants.ERROR), "\"" + INVALID_TOKEN + "\"");
String algs = map.get(OAuth2Constants.ALGS_ATTRIBUTE);
Assert.assertTrue(algs.contains(Algorithm.EdDSA));
Assert.assertTrue(algs.contains(Algorithm.RS256));
}
@Test
public void testDPoPBindEnforcerExecutor() throws Exception {
setInitialAccessTokenForDynamicClientRegistration();