diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 47a7d9408dd..b6eb64f56f8 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -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"; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java index 5a6dcd3039a..d7ff231dd50 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java @@ -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 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"); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java index 822e4c1b9d8..df1893d8e5d 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java @@ -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) { diff --git a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java index e4cc702bdab..70cec4cab5e 100755 --- a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java @@ -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 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; + } + } + } diff --git a/services/src/main/java/org/keycloak/utils/OAuth2Error.java b/services/src/main/java/org/keycloak/utils/OAuth2Error.java index f59cb7e16d4..c690a65279a 100644 --- a/services/src/main/java/org/keycloak/utils/OAuth2Error.java +++ b/services/src/main/java/org/keycloak/utils/OAuth2Error.java @@ -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 Dmitry Telegin @@ -49,7 +53,9 @@ public class OAuth2Error { private static final Map> STATUS_MAP = new HashMap<>(); + private KeycloakSession session; private RealmModel realm; + private String authScheme; private String error; private String errorDescription; private Optional 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 dpopAlgs = DPoPUtil.getDPoPSupportedAlgorithms(session); + dpopAlgs.stream() + .reduce((str1, current) -> str1 + " " + current) + .ifPresent(algs -> setAttribute(ALGS_ATTRIBUTE, algs)); + } + + @Override + public String getScheme() { + return DPOP_SCHEME; + } + + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java index 9f221941256..cf288923cf3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java @@ -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 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();