From 32b70255ae9d63a421b83da20721c7e27690e218 Mon Sep 17 00:00:00 2001 From: Jimmy Chakkalakal Date: Tue, 5 May 2026 10:30:18 +0100 Subject: [PATCH 1/3] Move AuthorizationCodeTest to the new testsuite Part of: #48201 Signed-off-by: Jimmy Chakkalakal --- .../tests/oauth/AuthorizationCodeTest.java | 632 ++++++++++++++++++ .../tests/pages/InstalledAppRedirectPage.java | 112 ++++ .../keycloak/tests/suites/JDKTestSuite.java | 4 +- .../oauth/AuthorizationCodeTest.java | 433 ------------ .../tests/base/testsuites/database-suite | 1 - .../tests/base/testsuites/jdk-suite | 1 - 6 files changed, 747 insertions(+), 436 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java delete mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java new file mode 100644 index 000000000000..1be7508836ba --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java @@ -0,0 +1,632 @@ +/* + * Copyright 2016 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.oauth; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; + +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseMode; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectEvents; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.events.Events; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.UserBuilder; +import org.keycloak.testframework.server.KeycloakUrls; +import org.keycloak.testframework.ui.annotations.InjectPage; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testframework.ui.page.ErrorPage; +import org.keycloak.testframework.ui.page.LoginPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import org.keycloak.tests.pages.InstalledAppRedirectPage; +import org.keycloak.tests.suites.DatabaseTest; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Stian Thorgersen + */ +@KeycloakIntegrationTest +public class AuthorizationCodeTest { + + static final String REALM_NAME = "test"; + + @InjectRealm(config = AuthorizationCodeRealmConfig.class, lifecycle = LifeCycle.CLASS) + ManagedRealm managedRealm; + + @InjectWebDriver + ManagedWebDriver driver; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectAdminClient + Keycloak adminClient; + + @InjectEvents + Events events; + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + @InjectPage + private ErrorPage errorPage; + + @InjectPage + private LoginPage loginPage; + + @InjectPage + InstalledAppRedirectPage installedAppPage; + + static class AuthorizationCodeRealmConfig implements RealmConfig { + @Override + public RealmBuilder configure(RealmBuilder realm) { + realm.name(REALM_NAME) + .eventsEnabled(true); + + realm.users(UserBuilder.create("test-user@localhost") + .name("Tom", "Brady") + .email("test-user@localhost") + .emailVerified(true) + .password("password") + .realmRoles("user", "offline_access")); + + realm.users(UserBuilder.create("john-doh@localhost") + .name("John", "Doh") + .email("john-doh@localhost") + .emailVerified(true) + .password("password") + .realmRoles("user")); + + realm.users(UserBuilder.create("keycloak-user@localhost") + .email("keycloak-user@localhost") + .emailVerified(true) + .password("password") + .realmRoles("user")); + + return realm; + } + } + + @BeforeEach + public void clientConfiguration() { + oauth.responseType(OAuth2Constants.CODE); + oauth.responseMode(null); + } + + @Test + public void authorizationRequest() { + managedRealm.dirty(); + AuthorizationEndpointResponse response = oauth.loginForm() + .state("OpenIdConnect.AuthenticationProperties=2302984sdlk") + .doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + assertNotNull(response.getCode()); + assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", response.getState()); + assertNull(response.getError()); + assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); + + EventRepresentation eventRepresentation = EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .getEvent(); + String codeId = eventRepresentation.getDetails().get(Details.CODE_ID); + assertNotNull(codeId); + } + + @Test + @DatabaseTest + public void authorizationRequestInstalledApp() { + managedRealm.dirty(); + + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.getRedirectUris().add(Constants.INSTALLED_APP_URN); + testAppClient.update(testAppRep); + + oauth.redirectUri(Constants.INSTALLED_APP_URN); + oauth.doLogin("test-user@localhost", "password"); + + assertTrue(driver.driver().getCurrentUrl().contains("/protocol/openid-connect/oauth/oob")); + + assertNotNull(driver.driver().findElement(By.id("code"))); + + EventRepresentation eventRepresentation = events.poll(); + assertNotNull(eventRepresentation); + EventAssertion.assertSuccess(eventRepresentation) + .type(EventType.LOGIN) + .details(Details.REDIRECT_URI, keycloakUrls.getBase() + "/realms/test/protocol/openid-connect/oauth/oob"); + + String codeId = eventRepresentation.getDetails().get(Details.CODE_ID); + assertNotNull(codeId); + } + + @Test + public void authorizationRequestInstalledAppErrors() { + String error = "

Back to application

"; + installedAppPage.open("test", null, error, null, keycloakUrls.getBase()); + + // Assert text escaped + installedAppPage.assertLinkBackToApplicationNotPresent(); + assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); + + error = "

Back to application

"; + installedAppPage.open("test", null, error, null, keycloakUrls.getBase()); + + // Assert text is escaped + installedAppPage.assertLinkBackToApplicationNotPresent(); + assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); + } + + @Test + @DatabaseTest + public void authorizationValidRedirectUri() { + managedRealm.dirty(); + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.getRedirectUris().add(oauth.getRedirectUri()); + testAppClient.update(testAppRep); + + AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + assertNotNull(response.getCode()); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + assertNotNull(codeId); + } + + @Test + @DatabaseTest + public void testInvalidRedirectUri() { + managedRealm.dirty(); + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.getRedirectUris().add(oauth.getRedirectUri()); + testAppClient.update(testAppRep); + oauth.redirectUri(oauth.getRedirectUri() + "%20test"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + oauth.redirectUri("ZAP%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%0A"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + } + + @Test + @DatabaseTest + public void testInvalidESCCharacterClientId() { + managedRealm.dirty(); + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.getRedirectUris().add(oauth.getRedirectUri()); + testAppClient.update(testAppRep); + + oauth.client("%1B"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("Client not found.", errorPage.getError()); + } + + @Test + @DatabaseTest + public void testInvalidNULCharacterClientId() { + managedRealm.dirty(); + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.getRedirectUris().add(oauth.getRedirectUri()); + testAppClient.update(testAppRep); + + oauth.client("%00"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("An internal server error has occurred", errorPage.getError()); + } + + @Test + public void authorizationRequestNoState() { + managedRealm.dirty(); + AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + assertNotNull(response.getCode()); + assertNull(response.getState()); + assertNull(response.getError()); + assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); + } + + @Test + public void authorizationRequestInvalidResponseType() { + managedRealm.dirty(); + + oauth.responseType("tokenn"); + oauth.openLoginForm(); + + AuthorizationEndpointResponse errorResponse = oauth.parseLoginResponse(); + assertTrue(errorResponse.isRedirected()); + assertEquals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, errorResponse.getError()); + assertEquals(keycloakUrls.getBase() + "/realms/test", errorResponse.getIssuer()); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REQUEST) + .userId(null) + .sessionId(null) + .details(Details.RESPONSE_TYPE, "tokenn"); + } + + // Issue 29866 + @Test + public void authorizationRequestInvalidResponseType_testHeaders() { + managedRealm.dirty(); + + oauth.responseType("tokenn"); + Client client = ClientBuilder.newClient(); + Response response = client.target(oauth.loginForm().build()).request().get(); + + assertThat(response.getStatus(), is(equalTo(302))); + String cacheControl = response.getHeaderString(HttpHeaders.CACHE_CONTROL); + assertNotNull(cacheControl); + assertThat(cacheControl, containsString("no-store")); + assertThat(cacheControl, containsString("must-revalidate")); + } + + @Test + public void authorizationRequestFormPostResponseModeInvalidResponseType() { + managedRealm.dirty(); + + String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType("tokenn"); + oauth.loginForm().state(requestState).open(); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REQUEST) + .userId(null) + .sessionId(null) + .details(Details.RESPONSE_TYPE, "tokenn") + .details(Details.REASON, "Unsupported response_type"); + } + + @Test + public void authorizationRequestFormPostResponseModeWithoutResponseType() { + String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(null); + oauth.loginForm().state(requestState).open(); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REQUEST) + .userId(null) + .sessionId(null) + .details(Details.REASON, "Missing parameter: response_type"); + } + + // KEYCLOAK-3281 + @Test + public void authorizationRequestFormPostResponseMode() { + String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.loginForm().state(requestState).open(); + + loginPage.fillLogin("test-user@localhost", "password"); + loginPage.submit(); + + try { + new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); + + String state = driver.driver().findElement(By.id("state")).getText(); + assertEquals(requestState, state); + assertNotNull(driver.driver().findElement(By.id("code")).getText()); + } catch (org.openqa.selenium.TimeoutException e) { + assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), + "Should be at callback or response page"); + } + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + assertNotNull(codeId); + } + + @Test + @DatabaseTest + public void authorizationRequestFormPostResponseModeInvalidRedirectUri() { + managedRealm.dirty(); + + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.setRedirectUris(Collections.singletonList("*")); + testAppClient.update(testAppRep); + + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(OAuth2Constants.CODE); + oauth.redirectUri("javascript:alert('XSS')"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REDIRECT_URI) + .userId(null) + .sessionId(null); + } + + @Test + @DatabaseTest + public void authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri() { + managedRealm.dirty(); + + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.setRedirectUris(Collections.singletonList("*")); + testAppClient.update(testAppRep); + + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(OAuth2Constants.CODE); + final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > + oauth.redirectUri(redirectUri); + + String requestState = "authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri"; + + oauth.loginForm().state(requestState).open(); + loginPage.fillLogin("test-user@localhost", "password"); + loginPage.submit(); + + try { + new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); + + String state = driver.driver().findElement(By.id("state")).getText(); + assertEquals(requestState, state); + assertNotNull(driver.driver().findElement(By.id("code")).getText()); + } catch (org.openqa.selenium.TimeoutException e) { + assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), + "Should be at callback or response page"); + } + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .details(Details.USERNAME, "test-user@localhost") + .details(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST.name().toLowerCase()) + .details(OAuth2Constants.REDIRECT_URI, redirectUri); + } + + @Test + @DatabaseTest + public void authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri() { + managedRealm.dirty(); + + ClientResource testAppClient = findClientByClientId("test-app"); + ClientRepresentation testAppRep = testAppClient.toRepresentation(); + testAppRep.setRedirectUris(Collections.singletonList("*")); + testAppClient.update(testAppRep); + + + oauth.responseMode(OIDCResponseMode.FORM_POST_JWT.value()); + oauth.responseType(OAuth2Constants.CODE); + final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > + oauth.redirectUri(redirectUri); + + String requestState = "authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri"; + oauth.loginForm().state(requestState).open(); + loginPage.fillLogin("test-user@localhost", "password"); + loginPage.submit(); + + try { + + new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("response"))); + + // if not properly encoded %3E would be received instead of > + String responseTokenEncoded = driver.driver().findElement(By.id("response")).getText(); + assertNotNull(responseTokenEncoded); + } catch (org.openqa.selenium.TimeoutException e) { + // The form may have auto-submitted already, check if we're at the callback + assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), + "Should be at callback or response page"); + } + + // Verify the redirect URI is correct (may be in current URL if auto-submitted) + assertTrue(driver.driver().getCurrentUrl().contains("?p=") || + driver.driver().getCurrentUrl().contains(">"), + "URL should contain the query parameter"); + + + // Verify login was successful via events + EventRepresentation loginEvent = events.poll(); + assertNotNull(loginEvent); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .details(Details.USERNAME, "test-user@localhost") + .details(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST_JWT.name().toLowerCase()) + .details(OAuth2Constants.REDIRECT_URI, redirectUri); + } + + @Test + public void authorizationRequestFormPostResponseModeWithCustomState() { + managedRealm.dirty(); + + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.loginForm().state("\">bar_baz(2)far").open(); + + loginPage.fillLogin("test-user@localhost", "password"); + loginPage.submit(); + + try { + new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); + + String state = driver.driver().findElement(By.id("state")).getText(); + assertEquals("\">bar_baz(2)far", state); + } catch (org.openqa.selenium.TimeoutException e) { + assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), + "Should be at callback or response page"); + } + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .details(Details.RESPONSE_MODE, OIDCResponseMode.FORM_POST.value()); + + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + assertNotNull(codeId); + } + + @Test + public void authorizationRequestFragmentResponseModeNotKept() throws Exception { + managedRealm.dirty(); + oauth.responseMode(OIDCResponseMode.FRAGMENT.value()); + AuthorizationEndpointResponse response = oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept").doLogin("test-user@localhost", "password"); + + assertNotNull(response.getCode()); + assertNotNull(response.getState()); + + URI currentUri = new URI(driver.driver().getCurrentUrl()); + assertNull(currentUri.getRawQuery()); + assertNotNull(currentUri.getRawFragment()); + + // Unset response_mode. The initial OIDC AuthenticationRequest won't contain "response_mode" parameter now and hence it should fallback to "query". + oauth.responseMode(null); + oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept2").open(); + response = oauth.parseLoginResponse(); + + assertNotNull(response.getCode()); + assertNotNull(response.getState()); + assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); + + currentUri = new URI(driver.driver().getCurrentUrl()); + assertNotNull(currentUri.getRawQuery()); + assertNull(currentUri.getRawFragment()); + } + + @Test + public void authorizationRequestParamsMoreThanOnce() { + managedRealm.dirty(); + + String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") + .queryParam(OAuth2Constants.STATE, "abcdefg") + .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); + + driver.driver().navigate().to(logoutUrl); + + AuthorizationEndpointResponse response = oauth.parseLoginResponse(); + + assertEquals("invalid_request", response.getError()); + assertEquals("duplicated parameter", response.getErrorDescription()); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REQUEST) + .userId(null) + .sessionId(null); + } + + @Test + public void authorizationRequestClientParamsMoreThanOnce() { + String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") + .queryParam(OAuth2Constants.CLIENT_ID, "client2client") + .queryParam(OAuth2Constants.REDIRECT_URI, "https://www.example.com") + .queryParam(OAuth2Constants.STATE, "abcdefg") + .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); + + driver.driver().navigate().to(logoutUrl); + + errorPage.assertCurrent(); + assertEquals("Invalid Request", errorPage.getError()); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .error(Errors.INVALID_REQUEST) + .userId(null) + .sessionId(null) + .clientId(null); + } + + private ClientResource findClientByClientId(String clientId) { + for (ClientRepresentation c : adminClient.realm(REALM_NAME).clients().findAll()) { + if (clientId.equals(c.getClientId())) { + return adminClient.realm(REALM_NAME).clients().get(c.getId()); + } + } + return null; + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java b/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java new file mode 100644 index 000000000000..bd0d49563c53 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java @@ -0,0 +1,112 @@ +/* + * Copyright 2022 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.pages; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.KeycloakUriBuilder; +import org.keycloak.services.Urls; +import org.keycloak.testframework.ui.page.AbstractPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.junit.jupiter.api.Assertions; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * Page represented by code.ftl. It is used by "Installed applications" (KeycloakInstalled) + * + * @author Marek Posolda + */ +public class InstalledAppRedirectPage extends AbstractPage { + + @FindBy(id = "code") + private WebElement code; + + @FindBy(id = "kc-page-title") + private WebElement pageTitle; + + @FindBy(css = "div[class^='pf-v5-c-alert'], div[class^='alert-error']") + private WebElement errorBox; + + public InstalledAppRedirectPage(ManagedWebDriver driver) { + super(driver); + } + + public void open(String realmName, String code, String error, String errorDescription, String oAuthServerRoot) { + try { + KeycloakUriBuilder kcUriBuilder = KeycloakUriBuilder.fromUri(Urls.realmInstalledAppUrnCallback(new URI(oAuthServerRoot), realmName)); + if (code != null) { + kcUriBuilder.queryParam(OAuth2Constants.CODE, code); + } + if (error != null) { + kcUriBuilder.queryParam(OAuth2Constants.ERROR, error); + } + if (errorDescription != null) { + kcUriBuilder.queryParam(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); + } + String oobEndpointUri = kcUriBuilder.build().toString(); + driver.driver().navigate().to(oobEndpointUri); + } catch (URISyntaxException use) { + throw new IllegalArgumentException(use); + } + } + + public boolean isCurrent() { + throw new UnsupportedOperationException("Use method 'isCurrentExpectSuccess' or 'isCurrentExpectError'"); + } + + + public String getSuccessCode() { + Assertions.assertEquals("Success code", getPageTitleText()); + return code.getAttribute("value"); + } + + public String getPageTitleText() { + return pageTitle.getText(); + } + + // Check if link is present inside title or error box + public void assertLinkBackToApplicationNotPresent() { + try { + pageTitle.findElement(By.tagName("a")); + throw new AssertionError("Link was present inside title"); + } catch (NoSuchElementException nsee) { + // Ignore + } + + try { + errorBox.findElement(By.tagName("a")); + throw new AssertionError("Link was present inside error box"); + } catch (NoSuchElementException nsee) { + // Ignore + } + + } + + @Override + public String getExpectedPageId() { + return ""; + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java b/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java index 03481ea79bf7..6f50add614e9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java +++ b/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java @@ -4,6 +4,7 @@ import org.keycloak.tests.forms.SSOTest; import org.keycloak.tests.keys.GeneratedRsaKeyProviderTest; import org.keycloak.tests.keys.JavaKeystoreKeyProviderTest; +import org.keycloak.tests.oauth.AuthorizationCodeTest; import org.keycloak.tests.policy.PasswordPolicyTest; import org.keycloak.tests.transactions.TransactionsTest; @@ -17,7 +18,8 @@ JavaKeystoreKeyProviderTest.class, PasswordPolicyTest.class, SSOTest.class, - TransactionsTest.class + TransactionsTest.class, + AuthorizationCodeTest.class }) public class JDKTestSuite { } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java deleted file mode 100755 index f75e73d4bb6f..000000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright 2016 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.testsuite.oauth; - -import java.io.IOException; -import java.net.URI; -import java.util.Collections; -import java.util.List; - -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; - -import org.keycloak.OAuth2Constants; -import org.keycloak.OAuthErrorException; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.events.EventType; -import org.keycloak.models.Constants; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.utils.OIDCResponseMode; -import org.keycloak.representations.AuthorizationResponseToken; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.testframework.events.EventAssertion; -import org.keycloak.testsuite.AbstractKeycloakTest; -import org.keycloak.testsuite.AssertEvents; -import org.keycloak.testsuite.pages.ErrorPage; -import org.keycloak.testsuite.pages.InstalledAppRedirectPage; -import org.keycloak.testsuite.updaters.ClientAttributeUpdater; -import org.keycloak.testsuite.util.AdminClientUtil; -import org.keycloak.testsuite.util.ClientManager; -import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; - -import org.jboss.arquillian.graphene.page.Page; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.jupiter.api.Assertions; -import org.openqa.selenium.By; - -import static org.keycloak.testsuite.AbstractAdminTest.loadJson; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * @author Stian Thorgersen - */ -public class AuthorizationCodeTest extends AbstractKeycloakTest { - - @Rule - public AssertEvents events = new AssertEvents(this); - - @Page - private ErrorPage errorPage; - - @Page - private InstalledAppRedirectPage installedAppPage; - - @Override - public void addTestRealms(List testRealms) { - RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); - testRealms.add(realmRepresentation); - } - - @Before - public void clientConfiguration() { - oauth.responseType(OAuth2Constants.CODE); - oauth.responseMode(null); - } - - @Test - public void authorizationRequest() throws IOException { - AuthorizationEndpointResponse response = oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - Assertions.assertNotNull(response.getCode()); - assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", response.getState()); - Assertions.assertNull(response.getError()); - assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); - - EventAssertion.expectLoginSuccess(events.poll()); - } - - @Test - public void authorizationRequestInstalledApp() throws IOException { - ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(Constants.INSTALLED_APP_URN); - oauth.redirectUri(Constants.INSTALLED_APP_URN); - - oauth.doLogin("test-user@localhost", "password"); - - installedAppPage.getSuccessCode(); - - EventAssertion.expectLoginSuccess(events.poll()).details(Details.REDIRECT_URI, oauth.AUTH_SERVER_ROOT + "/realms/test/protocol/openid-connect/oauth/oob"); - - ClientManager.realm(adminClient.realm("test")).clientId("test-app").removeRedirectUris(Constants.INSTALLED_APP_URN); - } - - @Test - public void authorizationRequestInstalledAppErrors() throws IOException { - String error = "

Back to application

"; - installedAppPage.open("test", null, error, null); - - // Assert text escaped - installedAppPage.assertLinkBackToApplicationNotPresent(); - Assertions.assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); - - error = "

Back to application

"; - installedAppPage.open("test", null, error, null); - - // Assert text is escaped - installedAppPage.assertLinkBackToApplicationNotPresent(); - Assertions.assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); - } - - @Test - public void authorizationValidRedirectUri() throws IOException { - ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); - - AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - Assertions.assertNotNull(response.getCode()); - - EventAssertion.expectLoginSuccess(events.poll()); - } - - @Test - public void testInvalidRedirectUri() { - ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); - - oauth.redirectUri(oauth.getRedirectUri() + "%20test"); - oauth.openLoginForm(); - - assertTrue(errorPage.isCurrent()); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - - oauth.redirectUri("ZAP%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%0A"); - oauth.openLoginForm(); - - assertTrue(errorPage.isCurrent()); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - } - - @Test - public void testInvalidNULCharacterClientId() { - ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); - - oauth.client("%00"); - oauth.openLoginForm(); - - assertTrue(errorPage.isCurrent()); - assertEquals("An internal server error has occurred", errorPage.getError()); - } - - @Test - public void testInvalidESCCharacterClientId() { - ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); - - oauth.client("%1B"); - oauth.openLoginForm(); - - assertTrue(errorPage.isCurrent()); - assertEquals("An internal server error has occurred", errorPage.getError()); - } - - @Test - public void authorizationRequestNoState() throws IOException { - AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - Assertions.assertNotNull(response.getCode()); - Assertions.assertNull(response.getState()); - Assertions.assertNull(response.getError()); - assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); - - EventAssertion.expectLoginSuccess(events.poll()); - } - - @Test - public void authorizationRequestInvalidResponseType() throws IOException { - oauth.responseType("tokenn"); - oauth.openLoginForm(); - - AuthorizationEndpointResponse errorResponse = oauth.parseLoginResponse(); - assertTrue(errorResponse.isRedirected()); - Assertions.assertEquals(errorResponse.getError(), OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE); - Assertions.assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", errorResponse.getIssuer()); - - EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null).details(Details.RESPONSE_TYPE, "tokenn"); - } - - // Issue 29866 - @Test - public void authorizationRequestInvalidResponseType_testHeaders() throws IOException { - oauth.responseType("tokenn"); - Client client = AdminClientUtil.createResteasyClient(); - Response response = client.target(oauth.loginForm().build()).request().get(); - - assertThat(response.getStatus(), is(equalTo(302))); - String cacheControl = response.getHeaderString(HttpHeaders.CACHE_CONTROL); - Assertions.assertNotNull(cacheControl); - Assert.assertThat(cacheControl, containsString("no-store")); - Assert.assertThat(cacheControl, containsString("must-revalidate")); - } - - @Test - public void authorizationRequestFormPostResponseModeInvalidResponseType() throws IOException { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType("tokenn"); - oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").open(); - - String error = driver.findElement(By.id("error")).getText(); - String state = driver.findElement(By.id("state")).getText(); - - assertEquals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, error); - assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); - - } - - @Test - public void authorizationRequestFormPostResponseModeWithoutResponseType() throws IOException { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(null); - oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").open(); - - String error = driver.findElement(By.id("error")).getText(); - String errorDescription = driver.findElement(By.id("error_description")).getText(); - String state = driver.findElement(By.id("state")).getText(); - - assertEquals(OAuthErrorException.INVALID_REQUEST, error); - assertEquals("Missing parameter: response_type", errorDescription); - assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); - - } - - // KEYCLOAK-3281 - @Test - public void authorizationRequestFormPostResponseMode() throws IOException { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").doLogin("test-user@localhost", "password"); - - String sources = driver.getPageSource(); - System.out.println(sources); - - String code = driver.findElement(By.id("code")).getText(); - String state = driver.findElement(By.id("state")).getText(); - - assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); - - EventAssertion.expectLoginSuccess(events.poll()); - } - - @Test - public void authorizationRequestFormPostResponseModeInvalidRedirectUri() throws IOException { - try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") - .setRedirectUris(Collections.singletonList("*")) - .update()) { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(OAuth2Constants.CODE); - oauth.redirectUri("javascript:alert('XSS')"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - - EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REDIRECT_URI).userId(null).sessionId(null); - } - } - - @Test - public void authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri() throws IOException { - try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") - .setRedirectUris(Collections.singletonList("*")) - .update()) { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(OAuth2Constants.CODE); - final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > - oauth.redirectUri(redirectUri); - - String requestState = "authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri"; - - oauth.loginForm().state(requestState).doLogin("test-user@localhost", "password"); - - WaitUtils.waitForPageToLoad(); - // if not properly encoded %3E would be received instead of > - Assertions.assertEquals(redirectUri, oauth.getDriver().getCurrentUrl(), "Redirect page was not encoded"); - String state = driver.findElement(By.id("state")).getText(); - Assertions.assertEquals(requestState, state); - Assertions.assertNotNull(driver.findElement(By.id("code")).getText()); - - events.expect(EventType.LOGIN) - .user(AssertEvents.isUUID()) - .session(AssertEvents.isSessionId()) - .detail(Details.USERNAME, "test-user@localhost") - .detail(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST.name().toLowerCase()) - .detail(OAuth2Constants.REDIRECT_URI, redirectUri) - .assertEvent(); - } - } - - @Test - public void authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri() throws IOException { - try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") - .setRedirectUris(Collections.singletonList("*")) - .update()) { - oauth.responseMode(OIDCResponseMode.FORM_POST_JWT.value()); - oauth.responseType(OAuth2Constants.CODE); - final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > - oauth.redirectUri(redirectUri); - - String requestState = "authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri"; - oauth.loginForm().state(requestState).doLogin("test-user@localhost", "password"); - - WaitUtils.waitForPageToLoad(); - // if not properly encoded %3E would be received instead of > - Assertions.assertEquals(redirectUri, oauth.getDriver().getCurrentUrl(), "Redirect page was not encoded"); - String responseTokenEncoded = driver.findElement(By.id("response")).getText(); - AuthorizationResponseToken responseToken = oauth.verifyAuthorizationResponseToken(responseTokenEncoded); - assertEquals("test-app", responseToken.getAudience()[0]); - Assertions.assertNotNull(responseToken.getOtherClaims().get("code")); - Assertions.assertNull(responseToken.getOtherClaims().get("error")); - Assertions.assertEquals(requestState, responseToken.getOtherClaims().get("state")); - Assertions.assertNotNull(responseToken.getOtherClaims().get("code")); - - events.expect(EventType.LOGIN) - .user(AssertEvents.isUUID()) - .session((String) responseToken.getOtherClaims().get("session_state")) - .detail(Details.USERNAME, "test-user@localhost") - .detail(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST_JWT.name().toLowerCase()) - .detail(OAuth2Constants.REDIRECT_URI, redirectUri) - .assertEvent(); - } - } - - @Test - public void authorizationRequestFormPostResponseModeWithCustomState() throws IOException { - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.loginForm().state("\">bar_baz(2)far").doLogin("test-user@localhost", "password"); - - String sources = driver.getPageSource(); - System.out.println(sources); - - String code = driver.findElement(By.id("code")).getText(); - String state = driver.findElement(By.id("state")).getText(); - - assertEquals("\">bar_baz(2)far", state); - - EventAssertion.expectLoginSuccess(events.poll()); - } - - - @Test - public void authorizationRequestFragmentResponseModeNotKept() throws Exception { - // Set response_mode=fragment and login - oauth.responseMode(OIDCResponseMode.FRAGMENT.value()); - AuthorizationEndpointResponse response = oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept").doLogin("test-user@localhost", "password"); - - Assertions.assertNotNull(response.getCode()); - Assertions.assertNotNull(response.getState()); - - URI currentUri = new URI(driver.getCurrentUrl()); - Assertions.assertNull(currentUri.getRawQuery()); - Assertions.assertNotNull(currentUri.getRawFragment()); - - // Unset response_mode. The initial OIDC AuthenticationRequest won't contain "response_mode" parameter now and hence it should fallback to "query". - oauth.responseMode(null); - oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept2").open(); - response = oauth.parseLoginResponse(); - - Assertions.assertNotNull(response.getCode()); - Assertions.assertNotNull(response.getState()); - Assertions.assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); - - currentUri = new URI(driver.getCurrentUrl()); - Assertions.assertNotNull(currentUri.getRawQuery()); - Assertions.assertNull(currentUri.getRawFragment()); - } - - @Test - public void authorizationRequestParamsMoreThanOnce() throws IOException { - String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") - .queryParam(OAuth2Constants.STATE, "abcdefg") - .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); - - driver.navigate().to(logoutUrl); - - AuthorizationEndpointResponse response = oauth.parseLoginResponse(); - - assertEquals("invalid_request", response.getError()); - assertEquals("duplicated parameter", response.getErrorDescription()); - - EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null); - } - - @Test - public void authorizationRequestClientParamsMoreThanOnce() throws IOException { - String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") - .queryParam(OAuth2Constants.CLIENT_ID, "client2client") - .queryParam(OAuth2Constants.REDIRECT_URI, "https://www.example.com") - .queryParam(OAuth2Constants.STATE, "abcdefg") - .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); - - driver.navigate().to(logoutUrl); - - assertTrue(errorPage.isCurrent()); - assertEquals("Invalid Request", errorPage.getError()); - - EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null).clientId(null); - } - -} diff --git a/testsuite/integration-arquillian/tests/base/testsuites/database-suite b/testsuite/integration-arquillian/tests/base/testsuites/database-suite index a36cef720cea..872a1e5cfab7 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/database-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/database-suite @@ -1,5 +1,4 @@ AccountRestServiceTest -AuthorizationCodeTest AuthorizationTest ClientRegistrationTest ExportImportTest diff --git a/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite b/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite index 2afa04e6c2cb..3cf9e7e3df67 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite @@ -1,5 +1,4 @@ AccountRestServiceTest -AuthorizationCodeTest DeployedScriptAuthenticatorTest ExportImportTest KcOidcBrokerTest From 946772c85220822c91bfa6a6c13ff92adaa03533 Mon Sep 17 00:00:00 2001 From: Jimmy Chakkalakal Date: Wed, 13 May 2026 10:57:14 +0100 Subject: [PATCH 2/3] Revert "Move AuthorizationCodeTest to the new testsuite" This reverts commit 32b70255ae9d63a421b83da20721c7e27690e218. --- .../tests/oauth/AuthorizationCodeTest.java | 632 ------------------ .../tests/pages/InstalledAppRedirectPage.java | 112 ---- .../keycloak/tests/suites/JDKTestSuite.java | 4 +- .../oauth/AuthorizationCodeTest.java | 433 ++++++++++++ .../tests/base/testsuites/database-suite | 1 + .../tests/base/testsuites/jdk-suite | 1 + 6 files changed, 436 insertions(+), 747 deletions(-) delete mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java delete mode 100644 tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java create mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java deleted file mode 100644 index 1be7508836ba..000000000000 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/AuthorizationCodeTest.java +++ /dev/null @@ -1,632 +0,0 @@ -/* - * Copyright 2016 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.oauth; - -import java.net.URI; -import java.time.Duration; -import java.util.Collections; - -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; - -import org.keycloak.OAuth2Constants; -import org.keycloak.OAuthErrorException; -import org.keycloak.admin.client.Keycloak; -import org.keycloak.admin.client.resource.ClientResource; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.events.EventType; -import org.keycloak.models.Constants; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.utils.OIDCResponseMode; -import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.testframework.annotations.InjectAdminClient; -import org.keycloak.testframework.annotations.InjectEvents; -import org.keycloak.testframework.annotations.InjectKeycloakUrls; -import org.keycloak.testframework.annotations.InjectRealm; -import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.testframework.events.EventAssertion; -import org.keycloak.testframework.events.Events; -import org.keycloak.testframework.injection.LifeCycle; -import org.keycloak.testframework.oauth.OAuthClient; -import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; -import org.keycloak.testframework.realm.ManagedRealm; -import org.keycloak.testframework.realm.RealmBuilder; -import org.keycloak.testframework.realm.RealmConfig; -import org.keycloak.testframework.realm.UserBuilder; -import org.keycloak.testframework.server.KeycloakUrls; -import org.keycloak.testframework.ui.annotations.InjectPage; -import org.keycloak.testframework.ui.annotations.InjectWebDriver; -import org.keycloak.testframework.ui.page.ErrorPage; -import org.keycloak.testframework.ui.page.LoginPage; -import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; -import org.keycloak.tests.pages.InstalledAppRedirectPage; -import org.keycloak.tests.suites.DatabaseTest; -import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.support.ui.ExpectedConditions; -import org.openqa.selenium.support.ui.WebDriverWait; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * @author Stian Thorgersen - */ -@KeycloakIntegrationTest -public class AuthorizationCodeTest { - - static final String REALM_NAME = "test"; - - @InjectRealm(config = AuthorizationCodeRealmConfig.class, lifecycle = LifeCycle.CLASS) - ManagedRealm managedRealm; - - @InjectWebDriver - ManagedWebDriver driver; - - @InjectOAuthClient - OAuthClient oauth; - - @InjectAdminClient - Keycloak adminClient; - - @InjectEvents - Events events; - - @InjectKeycloakUrls - KeycloakUrls keycloakUrls; - - @InjectPage - private ErrorPage errorPage; - - @InjectPage - private LoginPage loginPage; - - @InjectPage - InstalledAppRedirectPage installedAppPage; - - static class AuthorizationCodeRealmConfig implements RealmConfig { - @Override - public RealmBuilder configure(RealmBuilder realm) { - realm.name(REALM_NAME) - .eventsEnabled(true); - - realm.users(UserBuilder.create("test-user@localhost") - .name("Tom", "Brady") - .email("test-user@localhost") - .emailVerified(true) - .password("password") - .realmRoles("user", "offline_access")); - - realm.users(UserBuilder.create("john-doh@localhost") - .name("John", "Doh") - .email("john-doh@localhost") - .emailVerified(true) - .password("password") - .realmRoles("user")); - - realm.users(UserBuilder.create("keycloak-user@localhost") - .email("keycloak-user@localhost") - .emailVerified(true) - .password("password") - .realmRoles("user")); - - return realm; - } - } - - @BeforeEach - public void clientConfiguration() { - oauth.responseType(OAuth2Constants.CODE); - oauth.responseMode(null); - } - - @Test - public void authorizationRequest() { - managedRealm.dirty(); - AuthorizationEndpointResponse response = oauth.loginForm() - .state("OpenIdConnect.AuthenticationProperties=2302984sdlk") - .doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - assertNotNull(response.getCode()); - assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", response.getState()); - assertNull(response.getError()); - assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); - - EventRepresentation eventRepresentation = EventAssertion.assertSuccess(events.poll()) - .type(EventType.LOGIN) - .getEvent(); - String codeId = eventRepresentation.getDetails().get(Details.CODE_ID); - assertNotNull(codeId); - } - - @Test - @DatabaseTest - public void authorizationRequestInstalledApp() { - managedRealm.dirty(); - - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.getRedirectUris().add(Constants.INSTALLED_APP_URN); - testAppClient.update(testAppRep); - - oauth.redirectUri(Constants.INSTALLED_APP_URN); - oauth.doLogin("test-user@localhost", "password"); - - assertTrue(driver.driver().getCurrentUrl().contains("/protocol/openid-connect/oauth/oob")); - - assertNotNull(driver.driver().findElement(By.id("code"))); - - EventRepresentation eventRepresentation = events.poll(); - assertNotNull(eventRepresentation); - EventAssertion.assertSuccess(eventRepresentation) - .type(EventType.LOGIN) - .details(Details.REDIRECT_URI, keycloakUrls.getBase() + "/realms/test/protocol/openid-connect/oauth/oob"); - - String codeId = eventRepresentation.getDetails().get(Details.CODE_ID); - assertNotNull(codeId); - } - - @Test - public void authorizationRequestInstalledAppErrors() { - String error = "

Back to application

"; - installedAppPage.open("test", null, error, null, keycloakUrls.getBase()); - - // Assert text escaped - installedAppPage.assertLinkBackToApplicationNotPresent(); - assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); - - error = "

Back to application

"; - installedAppPage.open("test", null, error, null, keycloakUrls.getBase()); - - // Assert text is escaped - installedAppPage.assertLinkBackToApplicationNotPresent(); - assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); - } - - @Test - @DatabaseTest - public void authorizationValidRedirectUri() { - managedRealm.dirty(); - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.getRedirectUris().add(oauth.getRedirectUri()); - testAppClient.update(testAppRep); - - AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - assertNotNull(response.getCode()); - - EventRepresentation loginEvent = events.poll(); - EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - assertNotNull(codeId); - } - - @Test - @DatabaseTest - public void testInvalidRedirectUri() { - managedRealm.dirty(); - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.getRedirectUris().add(oauth.getRedirectUri()); - testAppClient.update(testAppRep); - oauth.redirectUri(oauth.getRedirectUri() + "%20test"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - - oauth.redirectUri("ZAP%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%0A"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - } - - @Test - @DatabaseTest - public void testInvalidESCCharacterClientId() { - managedRealm.dirty(); - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.getRedirectUris().add(oauth.getRedirectUri()); - testAppClient.update(testAppRep); - - oauth.client("%1B"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("Client not found.", errorPage.getError()); - } - - @Test - @DatabaseTest - public void testInvalidNULCharacterClientId() { - managedRealm.dirty(); - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.getRedirectUris().add(oauth.getRedirectUri()); - testAppClient.update(testAppRep); - - oauth.client("%00"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("An internal server error has occurred", errorPage.getError()); - } - - @Test - public void authorizationRequestNoState() { - managedRealm.dirty(); - AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); - - assertTrue(response.isRedirected()); - assertNotNull(response.getCode()); - assertNull(response.getState()); - assertNull(response.getError()); - assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); - - EventRepresentation loginEvent = events.poll(); - EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); - } - - @Test - public void authorizationRequestInvalidResponseType() { - managedRealm.dirty(); - - oauth.responseType("tokenn"); - oauth.openLoginForm(); - - AuthorizationEndpointResponse errorResponse = oauth.parseLoginResponse(); - assertTrue(errorResponse.isRedirected()); - assertEquals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, errorResponse.getError()); - assertEquals(keycloakUrls.getBase() + "/realms/test", errorResponse.getIssuer()); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REQUEST) - .userId(null) - .sessionId(null) - .details(Details.RESPONSE_TYPE, "tokenn"); - } - - // Issue 29866 - @Test - public void authorizationRequestInvalidResponseType_testHeaders() { - managedRealm.dirty(); - - oauth.responseType("tokenn"); - Client client = ClientBuilder.newClient(); - Response response = client.target(oauth.loginForm().build()).request().get(); - - assertThat(response.getStatus(), is(equalTo(302))); - String cacheControl = response.getHeaderString(HttpHeaders.CACHE_CONTROL); - assertNotNull(cacheControl); - assertThat(cacheControl, containsString("no-store")); - assertThat(cacheControl, containsString("must-revalidate")); - } - - @Test - public void authorizationRequestFormPostResponseModeInvalidResponseType() { - managedRealm.dirty(); - - String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType("tokenn"); - oauth.loginForm().state(requestState).open(); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REQUEST) - .userId(null) - .sessionId(null) - .details(Details.RESPONSE_TYPE, "tokenn") - .details(Details.REASON, "Unsupported response_type"); - } - - @Test - public void authorizationRequestFormPostResponseModeWithoutResponseType() { - String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(null); - oauth.loginForm().state(requestState).open(); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REQUEST) - .userId(null) - .sessionId(null) - .details(Details.REASON, "Missing parameter: response_type"); - } - - // KEYCLOAK-3281 - @Test - public void authorizationRequestFormPostResponseMode() { - String requestState = "OpenIdConnect.AuthenticationProperties=2302984sdlk"; - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.loginForm().state(requestState).open(); - - loginPage.fillLogin("test-user@localhost", "password"); - loginPage.submit(); - - try { - new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); - - String state = driver.driver().findElement(By.id("state")).getText(); - assertEquals(requestState, state); - assertNotNull(driver.driver().findElement(By.id("code")).getText()); - } catch (org.openqa.selenium.TimeoutException e) { - assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), - "Should be at callback or response page"); - } - - EventRepresentation loginEvent = events.poll(); - EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN); - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - assertNotNull(codeId); - } - - @Test - @DatabaseTest - public void authorizationRequestFormPostResponseModeInvalidRedirectUri() { - managedRealm.dirty(); - - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.setRedirectUris(Collections.singletonList("*")); - testAppClient.update(testAppRep); - - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(OAuth2Constants.CODE); - oauth.redirectUri("javascript:alert('XSS')"); - oauth.openLoginForm(); - - errorPage.assertCurrent(); - assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REDIRECT_URI) - .userId(null) - .sessionId(null); - } - - @Test - @DatabaseTest - public void authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri() { - managedRealm.dirty(); - - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.setRedirectUris(Collections.singletonList("*")); - testAppClient.update(testAppRep); - - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.responseType(OAuth2Constants.CODE); - final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > - oauth.redirectUri(redirectUri); - - String requestState = "authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri"; - - oauth.loginForm().state(requestState).open(); - loginPage.fillLogin("test-user@localhost", "password"); - loginPage.submit(); - - try { - new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); - - String state = driver.driver().findElement(By.id("state")).getText(); - assertEquals(requestState, state); - assertNotNull(driver.driver().findElement(By.id("code")).getText()); - } catch (org.openqa.selenium.TimeoutException e) { - assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), - "Should be at callback or response page"); - } - - EventRepresentation loginEvent = events.poll(); - EventAssertion.assertSuccess(loginEvent) - .type(EventType.LOGIN) - .details(Details.USERNAME, "test-user@localhost") - .details(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST.name().toLowerCase()) - .details(OAuth2Constants.REDIRECT_URI, redirectUri); - } - - @Test - @DatabaseTest - public void authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri() { - managedRealm.dirty(); - - ClientResource testAppClient = findClientByClientId("test-app"); - ClientRepresentation testAppRep = testAppClient.toRepresentation(); - testAppRep.setRedirectUris(Collections.singletonList("*")); - testAppClient.update(testAppRep); - - - oauth.responseMode(OIDCResponseMode.FORM_POST_JWT.value()); - oauth.responseType(OAuth2Constants.CODE); - final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > - oauth.redirectUri(redirectUri); - - String requestState = "authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri"; - oauth.loginForm().state(requestState).open(); - loginPage.fillLogin("test-user@localhost", "password"); - loginPage.submit(); - - try { - - new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("response"))); - - // if not properly encoded %3E would be received instead of > - String responseTokenEncoded = driver.driver().findElement(By.id("response")).getText(); - assertNotNull(responseTokenEncoded); - } catch (org.openqa.selenium.TimeoutException e) { - // The form may have auto-submitted already, check if we're at the callback - assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), - "Should be at callback or response page"); - } - - // Verify the redirect URI is correct (may be in current URL if auto-submitted) - assertTrue(driver.driver().getCurrentUrl().contains("?p=") || - driver.driver().getCurrentUrl().contains(">"), - "URL should contain the query parameter"); - - - // Verify login was successful via events - EventRepresentation loginEvent = events.poll(); - assertNotNull(loginEvent); - EventAssertion.assertSuccess(loginEvent) - .type(EventType.LOGIN) - .details(Details.USERNAME, "test-user@localhost") - .details(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST_JWT.name().toLowerCase()) - .details(OAuth2Constants.REDIRECT_URI, redirectUri); - } - - @Test - public void authorizationRequestFormPostResponseModeWithCustomState() { - managedRealm.dirty(); - - oauth.responseMode(OIDCResponseMode.FORM_POST.value()); - oauth.loginForm().state("\">bar_baz(2)far").open(); - - loginPage.fillLogin("test-user@localhost", "password"); - loginPage.submit(); - - try { - new WebDriverWait(driver.driver(), Duration.ofSeconds(2)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("state"))); - - String state = driver.driver().findElement(By.id("state")).getText(); - assertEquals("\">bar_baz(2)far", state); - } catch (org.openqa.selenium.TimeoutException e) { - assertTrue(driver.driver().getCurrentUrl().contains("/callback/oauth"), - "Should be at callback or response page"); - } - - EventRepresentation loginEvent = events.poll(); - EventAssertion.assertSuccess(loginEvent) - .type(EventType.LOGIN) - .details(Details.RESPONSE_MODE, OIDCResponseMode.FORM_POST.value()); - - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - assertNotNull(codeId); - } - - @Test - public void authorizationRequestFragmentResponseModeNotKept() throws Exception { - managedRealm.dirty(); - oauth.responseMode(OIDCResponseMode.FRAGMENT.value()); - AuthorizationEndpointResponse response = oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept").doLogin("test-user@localhost", "password"); - - assertNotNull(response.getCode()); - assertNotNull(response.getState()); - - URI currentUri = new URI(driver.driver().getCurrentUrl()); - assertNull(currentUri.getRawQuery()); - assertNotNull(currentUri.getRawFragment()); - - // Unset response_mode. The initial OIDC AuthenticationRequest won't contain "response_mode" parameter now and hence it should fallback to "query". - oauth.responseMode(null); - oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept2").open(); - response = oauth.parseLoginResponse(); - - assertNotNull(response.getCode()); - assertNotNull(response.getState()); - assertEquals(keycloakUrls.getBase() + "/realms/test", response.getIssuer()); - - currentUri = new URI(driver.driver().getCurrentUrl()); - assertNotNull(currentUri.getRawQuery()); - assertNull(currentUri.getRawFragment()); - } - - @Test - public void authorizationRequestParamsMoreThanOnce() { - managedRealm.dirty(); - - String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") - .queryParam(OAuth2Constants.STATE, "abcdefg") - .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); - - driver.driver().navigate().to(logoutUrl); - - AuthorizationEndpointResponse response = oauth.parseLoginResponse(); - - assertEquals("invalid_request", response.getError()); - assertEquals("duplicated parameter", response.getErrorDescription()); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REQUEST) - .userId(null) - .sessionId(null); - } - - @Test - public void authorizationRequestClientParamsMoreThanOnce() { - String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") - .queryParam(OAuth2Constants.CLIENT_ID, "client2client") - .queryParam(OAuth2Constants.REDIRECT_URI, "https://www.example.com") - .queryParam(OAuth2Constants.STATE, "abcdefg") - .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); - - driver.driver().navigate().to(logoutUrl); - - errorPage.assertCurrent(); - assertEquals("Invalid Request", errorPage.getError()); - - EventRepresentation errorEvent = events.poll(); - EventAssertion.assertError(errorEvent) - .type(EventType.LOGIN_ERROR) - .error(Errors.INVALID_REQUEST) - .userId(null) - .sessionId(null) - .clientId(null); - } - - private ClientResource findClientByClientId(String clientId) { - for (ClientRepresentation c : adminClient.realm(REALM_NAME).clients().findAll()) { - if (clientId.equals(c.getClientId())) { - return adminClient.realm(REALM_NAME).clients().get(c.getId()); - } - } - return null; - } - -} diff --git a/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java b/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java deleted file mode 100644 index bd0d49563c53..000000000000 --- a/tests/base/src/test/java/org/keycloak/tests/pages/InstalledAppRedirectPage.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2022 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.pages; - -import java.net.URI; -import java.net.URISyntaxException; - -import org.keycloak.OAuth2Constants; -import org.keycloak.common.util.KeycloakUriBuilder; -import org.keycloak.services.Urls; -import org.keycloak.testframework.ui.page.AbstractPage; -import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; - -import org.junit.jupiter.api.Assertions; -import org.openqa.selenium.By; -import org.openqa.selenium.NoSuchElementException; -import org.openqa.selenium.WebElement; -import org.openqa.selenium.support.FindBy; - -/** - * Page represented by code.ftl. It is used by "Installed applications" (KeycloakInstalled) - * - * @author Marek Posolda - */ -public class InstalledAppRedirectPage extends AbstractPage { - - @FindBy(id = "code") - private WebElement code; - - @FindBy(id = "kc-page-title") - private WebElement pageTitle; - - @FindBy(css = "div[class^='pf-v5-c-alert'], div[class^='alert-error']") - private WebElement errorBox; - - public InstalledAppRedirectPage(ManagedWebDriver driver) { - super(driver); - } - - public void open(String realmName, String code, String error, String errorDescription, String oAuthServerRoot) { - try { - KeycloakUriBuilder kcUriBuilder = KeycloakUriBuilder.fromUri(Urls.realmInstalledAppUrnCallback(new URI(oAuthServerRoot), realmName)); - if (code != null) { - kcUriBuilder.queryParam(OAuth2Constants.CODE, code); - } - if (error != null) { - kcUriBuilder.queryParam(OAuth2Constants.ERROR, error); - } - if (errorDescription != null) { - kcUriBuilder.queryParam(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); - } - String oobEndpointUri = kcUriBuilder.build().toString(); - driver.driver().navigate().to(oobEndpointUri); - } catch (URISyntaxException use) { - throw new IllegalArgumentException(use); - } - } - - public boolean isCurrent() { - throw new UnsupportedOperationException("Use method 'isCurrentExpectSuccess' or 'isCurrentExpectError'"); - } - - - public String getSuccessCode() { - Assertions.assertEquals("Success code", getPageTitleText()); - return code.getAttribute("value"); - } - - public String getPageTitleText() { - return pageTitle.getText(); - } - - // Check if link is present inside title or error box - public void assertLinkBackToApplicationNotPresent() { - try { - pageTitle.findElement(By.tagName("a")); - throw new AssertionError("Link was present inside title"); - } catch (NoSuchElementException nsee) { - // Ignore - } - - try { - errorBox.findElement(By.tagName("a")); - throw new AssertionError("Link was present inside error box"); - } catch (NoSuchElementException nsee) { - // Ignore - } - - } - - @Override - public String getExpectedPageId() { - return ""; - } -} diff --git a/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java b/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java index 6f50add614e9..03481ea79bf7 100644 --- a/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java +++ b/tests/base/src/test/java/org/keycloak/tests/suites/JDKTestSuite.java @@ -4,7 +4,6 @@ import org.keycloak.tests.forms.SSOTest; import org.keycloak.tests.keys.GeneratedRsaKeyProviderTest; import org.keycloak.tests.keys.JavaKeystoreKeyProviderTest; -import org.keycloak.tests.oauth.AuthorizationCodeTest; import org.keycloak.tests.policy.PasswordPolicyTest; import org.keycloak.tests.transactions.TransactionsTest; @@ -18,8 +17,7 @@ JavaKeystoreKeyProviderTest.class, PasswordPolicyTest.class, SSOTest.class, - TransactionsTest.class, - AuthorizationCodeTest.class + TransactionsTest.class }) public class JDKTestSuite { } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java new file mode 100755 index 000000000000..f75e73d4bb6f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java @@ -0,0 +1,433 @@ +/* + * Copyright 2016 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.testsuite.oauth; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; + +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseMode; +import org.keycloak.representations.AuthorizationResponseToken; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.pages.ErrorPage; +import org.keycloak.testsuite.pages.InstalledAppRedirectPage; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.WaitUtils; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.openqa.selenium.By; + +import static org.keycloak.testsuite.AbstractAdminTest.loadJson; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Stian Thorgersen + */ +public class AuthorizationCodeTest extends AbstractKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + private ErrorPage errorPage; + + @Page + private InstalledAppRedirectPage installedAppPage; + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + testRealms.add(realmRepresentation); + } + + @Before + public void clientConfiguration() { + oauth.responseType(OAuth2Constants.CODE); + oauth.responseMode(null); + } + + @Test + public void authorizationRequest() throws IOException { + AuthorizationEndpointResponse response = oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + Assertions.assertNotNull(response.getCode()); + assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", response.getState()); + Assertions.assertNull(response.getError()); + assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); + + EventAssertion.expectLoginSuccess(events.poll()); + } + + @Test + public void authorizationRequestInstalledApp() throws IOException { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(Constants.INSTALLED_APP_URN); + oauth.redirectUri(Constants.INSTALLED_APP_URN); + + oauth.doLogin("test-user@localhost", "password"); + + installedAppPage.getSuccessCode(); + + EventAssertion.expectLoginSuccess(events.poll()).details(Details.REDIRECT_URI, oauth.AUTH_SERVER_ROOT + "/realms/test/protocol/openid-connect/oauth/oob"); + + ClientManager.realm(adminClient.realm("test")).clientId("test-app").removeRedirectUris(Constants.INSTALLED_APP_URN); + } + + @Test + public void authorizationRequestInstalledAppErrors() throws IOException { + String error = "

Back to application

"; + installedAppPage.open("test", null, error, null); + + // Assert text escaped + installedAppPage.assertLinkBackToApplicationNotPresent(); + Assertions.assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); + + error = "

Back to application

"; + installedAppPage.open("test", null, error, null); + + // Assert text is escaped + installedAppPage.assertLinkBackToApplicationNotPresent(); + Assertions.assertEquals("Error code:

Back to application

", installedAppPage.getPageTitleText()); + } + + @Test + public void authorizationValidRedirectUri() throws IOException { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); + + AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + Assertions.assertNotNull(response.getCode()); + + EventAssertion.expectLoginSuccess(events.poll()); + } + + @Test + public void testInvalidRedirectUri() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); + + oauth.redirectUri(oauth.getRedirectUri() + "%20test"); + oauth.openLoginForm(); + + assertTrue(errorPage.isCurrent()); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + oauth.redirectUri("ZAP%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%25n%25s%0A"); + oauth.openLoginForm(); + + assertTrue(errorPage.isCurrent()); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + } + + @Test + public void testInvalidNULCharacterClientId() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); + + oauth.client("%00"); + oauth.openLoginForm(); + + assertTrue(errorPage.isCurrent()); + assertEquals("An internal server error has occurred", errorPage.getError()); + } + + @Test + public void testInvalidESCCharacterClientId() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addRedirectUris(oauth.getRedirectUri()); + + oauth.client("%1B"); + oauth.openLoginForm(); + + assertTrue(errorPage.isCurrent()); + assertEquals("An internal server error has occurred", errorPage.getError()); + } + + @Test + public void authorizationRequestNoState() throws IOException { + AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + + assertTrue(response.isRedirected()); + Assertions.assertNotNull(response.getCode()); + Assertions.assertNull(response.getState()); + Assertions.assertNull(response.getError()); + assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); + + EventAssertion.expectLoginSuccess(events.poll()); + } + + @Test + public void authorizationRequestInvalidResponseType() throws IOException { + oauth.responseType("tokenn"); + oauth.openLoginForm(); + + AuthorizationEndpointResponse errorResponse = oauth.parseLoginResponse(); + assertTrue(errorResponse.isRedirected()); + Assertions.assertEquals(errorResponse.getError(), OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE); + Assertions.assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", errorResponse.getIssuer()); + + EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null).details(Details.RESPONSE_TYPE, "tokenn"); + } + + // Issue 29866 + @Test + public void authorizationRequestInvalidResponseType_testHeaders() throws IOException { + oauth.responseType("tokenn"); + Client client = AdminClientUtil.createResteasyClient(); + Response response = client.target(oauth.loginForm().build()).request().get(); + + assertThat(response.getStatus(), is(equalTo(302))); + String cacheControl = response.getHeaderString(HttpHeaders.CACHE_CONTROL); + Assertions.assertNotNull(cacheControl); + Assert.assertThat(cacheControl, containsString("no-store")); + Assert.assertThat(cacheControl, containsString("must-revalidate")); + } + + @Test + public void authorizationRequestFormPostResponseModeInvalidResponseType() throws IOException { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType("tokenn"); + oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").open(); + + String error = driver.findElement(By.id("error")).getText(); + String state = driver.findElement(By.id("state")).getText(); + + assertEquals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, error); + assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); + + } + + @Test + public void authorizationRequestFormPostResponseModeWithoutResponseType() throws IOException { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(null); + oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").open(); + + String error = driver.findElement(By.id("error")).getText(); + String errorDescription = driver.findElement(By.id("error_description")).getText(); + String state = driver.findElement(By.id("state")).getText(); + + assertEquals(OAuthErrorException.INVALID_REQUEST, error); + assertEquals("Missing parameter: response_type", errorDescription); + assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); + + } + + // KEYCLOAK-3281 + @Test + public void authorizationRequestFormPostResponseMode() throws IOException { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.loginForm().state("OpenIdConnect.AuthenticationProperties=2302984sdlk").doLogin("test-user@localhost", "password"); + + String sources = driver.getPageSource(); + System.out.println(sources); + + String code = driver.findElement(By.id("code")).getText(); + String state = driver.findElement(By.id("state")).getText(); + + assertEquals("OpenIdConnect.AuthenticationProperties=2302984sdlk", state); + + EventAssertion.expectLoginSuccess(events.poll()); + } + + @Test + public void authorizationRequestFormPostResponseModeInvalidRedirectUri() throws IOException { + try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") + .setRedirectUris(Collections.singletonList("*")) + .update()) { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(OAuth2Constants.CODE); + oauth.redirectUri("javascript:alert('XSS')"); + oauth.openLoginForm(); + + errorPage.assertCurrent(); + assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REDIRECT_URI).userId(null).sessionId(null); + } + } + + @Test + public void authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri() throws IOException { + try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") + .setRedirectUris(Collections.singletonList("*")) + .update()) { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.responseType(OAuth2Constants.CODE); + final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > + oauth.redirectUri(redirectUri); + + String requestState = "authorizationRequestFormPostResponseModeHTMLEntitiesRedirectUri"; + + oauth.loginForm().state(requestState).doLogin("test-user@localhost", "password"); + + WaitUtils.waitForPageToLoad(); + // if not properly encoded %3E would be received instead of > + Assertions.assertEquals(redirectUri, oauth.getDriver().getCurrentUrl(), "Redirect page was not encoded"); + String state = driver.findElement(By.id("state")).getText(); + Assertions.assertEquals(requestState, state); + Assertions.assertNotNull(driver.findElement(By.id("code")).getText()); + + events.expect(EventType.LOGIN) + .user(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) + .detail(Details.USERNAME, "test-user@localhost") + .detail(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST.name().toLowerCase()) + .detail(OAuth2Constants.REDIRECT_URI, redirectUri) + .assertEvent(); + } + } + + @Test + public void authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri() throws IOException { + try (var c = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") + .setRedirectUris(Collections.singletonList("*")) + .update()) { + oauth.responseMode(OIDCResponseMode.FORM_POST_JWT.value()); + oauth.responseType(OAuth2Constants.CODE); + final String redirectUri = oauth.getRedirectUri() + "?p=>"; // set HTML entity > + oauth.redirectUri(redirectUri); + + String requestState = "authorizationRequestFormPostJwtResponseModeHTMLEntitiesRedirectUri"; + oauth.loginForm().state(requestState).doLogin("test-user@localhost", "password"); + + WaitUtils.waitForPageToLoad(); + // if not properly encoded %3E would be received instead of > + Assertions.assertEquals(redirectUri, oauth.getDriver().getCurrentUrl(), "Redirect page was not encoded"); + String responseTokenEncoded = driver.findElement(By.id("response")).getText(); + AuthorizationResponseToken responseToken = oauth.verifyAuthorizationResponseToken(responseTokenEncoded); + assertEquals("test-app", responseToken.getAudience()[0]); + Assertions.assertNotNull(responseToken.getOtherClaims().get("code")); + Assertions.assertNull(responseToken.getOtherClaims().get("error")); + Assertions.assertEquals(requestState, responseToken.getOtherClaims().get("state")); + Assertions.assertNotNull(responseToken.getOtherClaims().get("code")); + + events.expect(EventType.LOGIN) + .user(AssertEvents.isUUID()) + .session((String) responseToken.getOtherClaims().get("session_state")) + .detail(Details.USERNAME, "test-user@localhost") + .detail(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST_JWT.name().toLowerCase()) + .detail(OAuth2Constants.REDIRECT_URI, redirectUri) + .assertEvent(); + } + } + + @Test + public void authorizationRequestFormPostResponseModeWithCustomState() throws IOException { + oauth.responseMode(OIDCResponseMode.FORM_POST.value()); + oauth.loginForm().state("\">bar_baz(2)far").doLogin("test-user@localhost", "password"); + + String sources = driver.getPageSource(); + System.out.println(sources); + + String code = driver.findElement(By.id("code")).getText(); + String state = driver.findElement(By.id("state")).getText(); + + assertEquals("\">bar_baz(2)far", state); + + EventAssertion.expectLoginSuccess(events.poll()); + } + + + @Test + public void authorizationRequestFragmentResponseModeNotKept() throws Exception { + // Set response_mode=fragment and login + oauth.responseMode(OIDCResponseMode.FRAGMENT.value()); + AuthorizationEndpointResponse response = oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept").doLogin("test-user@localhost", "password"); + + Assertions.assertNotNull(response.getCode()); + Assertions.assertNotNull(response.getState()); + + URI currentUri = new URI(driver.getCurrentUrl()); + Assertions.assertNull(currentUri.getRawQuery()); + Assertions.assertNotNull(currentUri.getRawFragment()); + + // Unset response_mode. The initial OIDC AuthenticationRequest won't contain "response_mode" parameter now and hence it should fallback to "query". + oauth.responseMode(null); + oauth.loginForm().state("authorizationRequestFragmentResponseModeNotKept2").open(); + response = oauth.parseLoginResponse(); + + Assertions.assertNotNull(response.getCode()); + Assertions.assertNotNull(response.getState()); + Assertions.assertEquals(oauth.AUTH_SERVER_ROOT + "/realms/test", response.getIssuer()); + + currentUri = new URI(driver.getCurrentUrl()); + Assertions.assertNotNull(currentUri.getRawQuery()); + Assertions.assertNull(currentUri.getRawFragment()); + } + + @Test + public void authorizationRequestParamsMoreThanOnce() throws IOException { + String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") + .queryParam(OAuth2Constants.STATE, "abcdefg") + .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); + + driver.navigate().to(logoutUrl); + + AuthorizationEndpointResponse response = oauth.parseLoginResponse(); + + assertEquals("invalid_request", response.getError()); + assertEquals("duplicated parameter", response.getErrorDescription()); + + EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null); + } + + @Test + public void authorizationRequestClientParamsMoreThanOnce() throws IOException { + String logoutUrl = UriBuilder.fromUri(oauth.loginForm().build()).queryParam(OAuth2Constants.SCOPE, "read_write") + .queryParam(OAuth2Constants.CLIENT_ID, "client2client") + .queryParam(OAuth2Constants.REDIRECT_URI, "https://www.example.com") + .queryParam(OAuth2Constants.STATE, "abcdefg") + .queryParam(OAuth2Constants.SCOPE, "pop push").build().toString(); + + driver.navigate().to(logoutUrl); + + assertTrue(errorPage.isCurrent()); + assertEquals("Invalid Request", errorPage.getError()); + + EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null).clientId(null); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/testsuites/database-suite b/testsuite/integration-arquillian/tests/base/testsuites/database-suite index 872a1e5cfab7..a36cef720cea 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/database-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/database-suite @@ -1,4 +1,5 @@ AccountRestServiceTest +AuthorizationCodeTest AuthorizationTest ClientRegistrationTest ExportImportTest diff --git a/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite b/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite index 3cf9e7e3df67..2afa04e6c2cb 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/jdk-suite @@ -1,4 +1,5 @@ AccountRestServiceTest +AuthorizationCodeTest DeployedScriptAuthenticatorTest ExportImportTest KcOidcBrokerTest From eba0ef3c3bc3d86d7b248eb8dd7dd642ec2245b7 Mon Sep 17 00:00:00 2001 From: Jimmy Chakkalakal Date: Fri, 22 May 2026 15:38:21 +0100 Subject: [PATCH 3/3] feat(oid4vci): add issued VC tracking and admin REST endpoint - Add database schema for tracking issued verifiable credentials - Implement admin REST API endpoints for managing issued credentials - Add support for credential revocation tracking - Include tests for new functionality Fixes #46204 Signed-off-by: Jimmy Chakkalakal --- ...uedVerifiableCredentialRepresentation.java | 84 +++++++ .../UserVerifiableCredentialResource.java | 6 +- .../cache/infinispan/UserCacheSession.java | 11 + .../keycloak/models/jpa/JpaUserProvider.java | 60 +++++ .../IssuedVerifiableCredentialEntity.java | 118 ++++++++++ .../META-INF/jpa-changelog-26.7.0.xml | 35 +++ .../main/resources/default-persistence.xml | 1 + .../keycloak/storage/UserStorageManager.java | 19 ++ .../models/utils/ModelToRepresentation.java | 15 ++ .../models/utils/RepresentationToModel.java | 14 ++ .../IssuedVerifiableCredentialModel.java | 78 +++++++ .../org/keycloak/models/UserProvider.java | 16 ++ .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 16 ++ .../UserVerifiableCredentialResource.java | 19 ++ .../keycloak/tests/admin/PermissionsTest.java | 1 + .../user/IssuedVerifiableCredentialTest.java | 218 ++++++++++++++++++ .../OID4VCIssuedCredentialsAdminAPITest.java | 105 +++++++++ 17 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java create mode 100644 server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java diff --git a/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java new file mode 100644 index 000000000000..dc97bdaa7782 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/oid4vc/IssuedVerifiableCredentialRepresentation.java @@ -0,0 +1,84 @@ +package org.keycloak.representations.idm.oid4vc; + +import java.util.Objects; + +public class IssuedVerifiableCredentialRepresentation { + + private String id; + private String userId; + private String credentialType; + private Long issuedAt; + private Long expiresAt; + // This represents UUID of the client, which acts as OID4VCI wallet + private String clientId; + private String revision; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getCredentialType() { + return credentialType; + } + + public void setCredentialType(String credentialType) { + this.credentialType = credentialType; + } + + public Long getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(Long issuedAt) { + this.issuedAt = issuedAt; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IssuedVerifiableCredentialRepresentation that = (IssuedVerifiableCredentialRepresentation) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java index 5e8149b2d6dc..28f084af1b30 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserVerifiableCredentialResource.java @@ -12,6 +12,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; /** @@ -40,5 +41,8 @@ public interface UserVerifiableCredentialResource { @Produces(MediaType.APPLICATION_JSON) UserVerifiableCredentialRepresentation updateCredential(@PathParam("credentialScopeName") String credentialScopeName); - // TODO: Issued credentials + @GET + @Path("issued-credentials") + @Produces(MediaType.APPLICATION_JSON) + List getIssuedCredentials(); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index fe529db23baf..9a53de2a09b3 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -39,6 +39,7 @@ import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakTransaction; import org.keycloak.models.ProtocolMapperModel; @@ -875,6 +876,16 @@ public UserVerifiableCredentialModel updateVerifiableCredential(String userId, S return getDelegate().updateVerifiableCredential(userId, credentialScopeName); } + @Override + public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc) { + getDelegate().addIssuedVerifiableCredential(issuedVc); + } + + @Override + public Stream getIssuedVerifiableCredentialsStreamByUser(String userId) { + return getDelegate().getIssuedVerifiableCredentialsStreamByUser(userId); + } + @Override public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) { if (!isRegisteredForInvalidation(realm, user.getId())) { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 3d7d37c7b566..9884f7781d61 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -54,6 +54,7 @@ import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelException; @@ -68,6 +69,7 @@ import org.keycloak.models.UserVerifiableCredentialModel; import org.keycloak.models.jpa.entities.CredentialEntity; import org.keycloak.models.jpa.entities.FederatedIdentityEntity; +import org.keycloak.models.jpa.entities.IssuedVerifiableCredentialEntity; import org.keycloak.models.jpa.entities.UserAttributeEntity; import org.keycloak.models.jpa.entities.UserConsentClientScopeEntity; import org.keycloak.models.jpa.entities.UserConsentEntity; @@ -170,6 +172,7 @@ private void removeUser(UserEntity user) { em.createNamedQuery("deleteUserConsentClientScopesByUser").setParameter("user", user).executeUpdate(); em.createNamedQuery("deleteUserConsentsByUser").setParameter("user", user).executeUpdate(); em.createNamedQuery("deleteVerifiableCredentialsByUser").setParameter("user", user).executeUpdate(); + em.createNamedQuery("deleteIssuedVcsByUser").setParameter("userId", user.getId()).executeUpdate(); em.remove(user); em.flush(); @@ -518,6 +521,8 @@ public void preRemove(RealmModel realm) { .setParameter("realmId", realm.getId()).executeUpdate(); em.createNamedQuery("deleteVerifiableCredentialsByRealm") .setParameter("realmId", realm.getId()).executeUpdate(); + em.createNamedQuery("deleteIssuedVcsByRealm") + .setParameter("realmId", realm.getId()).executeUpdate(); em.createNamedQuery("deleteUserRoleMappingsByRealm") .setParameter("realmId", realm.getId()).executeUpdate(); em.createNamedQuery("deleteUserRequiredActionsByRealm") @@ -623,6 +628,9 @@ public void preRemove(ClientScopeModel clientScope) { em.createNamedQuery("deleteVerifiableCredentialsByClientScope") .setParameter("scopeName", clientScope.getName()) .executeUpdate(); + em.createNamedQuery("deleteIssuedVcsByClientScope") + .setParameter("scopeName", clientScope.getName()) + .executeUpdate(); } @Override @@ -1080,6 +1088,45 @@ public UserCredentialManager getUserCredentialManager(UserModel user) { return new org.keycloak.credential.UserCredentialManager(session, session.getContext().getRealm(), user); } + @Override + public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel model) { + + String revision; + if (model.getRevision() != null) { + revision = model.getRevision(); + } else { + UserVerifiableCredentialEntity userVerifiableCredentialEntity = getVerifiableCredentialsEntitiesByUser(model.getUserId()) + .filter(vc -> model.getCredentialType().equals(vc.getCredentialScopeName())) + .findFirst() + .orElseThrow(() -> new ModelException( + "Verifiable credential not found: " + model.getCredentialType())); + revision = userVerifiableCredentialEntity.getRevision(); + } + + IssuedVerifiableCredentialEntity issuedVerifiableCredentialEntity = new IssuedVerifiableCredentialEntity(); + + issuedVerifiableCredentialEntity.setId(KeycloakModelUtils.generateId()); + issuedVerifiableCredentialEntity.setUser(em.getReference(UserEntity.class, model.getUserId())); + issuedVerifiableCredentialEntity.setCredentialType(model.getCredentialType()); + issuedVerifiableCredentialEntity.setClientId(model.getClientId()); + issuedVerifiableCredentialEntity.setRevision(revision); + + long issuedAt = model.getIssuedAt() != null ? model.getIssuedAt() : Time.currentTimeMillis(); + issuedVerifiableCredentialEntity.setIssuedAt(issuedAt); + + issuedVerifiableCredentialEntity.setExpiresAt(model.getExpiresAt()); + + em.persist(issuedVerifiableCredentialEntity); + em.flush(); + } + + @Override + public Stream getIssuedVerifiableCredentialsStreamByUser(String userId) { + TypedQuery query = em.createNamedQuery("issuedVcsByUser", IssuedVerifiableCredentialEntity.class); + query.setParameter("userId", userId); + return closing(query.getResultStream()).map(this::toIssuedVcModel); + } + // Could override this to provide a custom behavior. protected void ensureEmailConstraint(List users, RealmModel realm) { UserEntity user = users.get(0); @@ -1260,4 +1307,17 @@ private static CriteriaQuery countQuery(CriteriaQuery query, Criteri return query.select(from.getJoins().isEmpty() ? builder.count(from) : builder.countDistinct(from)) .where(predicates); } + + private IssuedVerifiableCredentialModel toIssuedVcModel(IssuedVerifiableCredentialEntity entity) { + IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(); + model.setId(entity.getId()); + model.setUserId(entity.getUser().getId()); + model.setCredentialType(entity.getCredentialType()); + model.setRevision(entity.getRevision()); + model.setIssuedAt(entity.getIssuedAt()); + model.setExpiresAt(entity.getExpiresAt()); + model.setClientId(entity.getClientId()); + + return model; + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java new file mode 100644 index 000000000000..74e53aa353cd --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IssuedVerifiableCredentialEntity.java @@ -0,0 +1,118 @@ +package org.keycloak.models.jpa.entities; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; + +@Entity +@Table(name="ISSUED_VER_CREDENTIAL") +@NamedQueries({ + @NamedQuery(name="issuedVcsByUser", query="select vc from IssuedVerifiableCredentialEntity vc where vc.user.id = :userId order by vc.issuedAt desc"), + @NamedQuery(name="deleteIssuedVcsByRealm", query="delete from IssuedVerifiableCredentialEntity vc where vc.user IN (select u from UserEntity u where u.realmId = :realmId)"), + @NamedQuery(name="deleteIssuedVcsByUser", query="delete from IssuedVerifiableCredentialEntity vc where vc.user.id = :userId"), + @NamedQuery(name="deleteIssuedVcsByClientScope", query="delete from IssuedVerifiableCredentialEntity vc where vc.credentialType = :scopeName"), +}) +public class IssuedVerifiableCredentialEntity { + + @Id + @Column(name="ID", length = 36) + @Access(AccessType.PROPERTY) + protected String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="USER_ID") + protected UserEntity user; + + @Column(name="CREDENTIAL_TYPE") + protected String credentialType; + + @Column(name="ISSUED_AT") + protected Long issuedAt; + + @Column(name="EXPIRES_AT") + protected Long expiresAt; + + // This represents UUID of the client, which acts as OID4VCI wallet + @Column(name="CLIENT_ID") + protected String clientId; + + @Column(name="REVISION") + protected String revision; + + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + public String getCredentialType() { + return credentialType; + } + + public void setCredentialType(String credentialType) { + this.credentialType = credentialType; + } + + public Long getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(Long issuedAt) { + this.issuedAt = issuedAt; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IssuedVerifiableCredentialEntity that = (IssuedVerifiableCredentialEntity) o; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml index 7fa2be38636a..6a871ae8c81e 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.7.0.xml @@ -203,4 +203,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml index 3f7b039cd050..f0d5316ef2b1 100644 --- a/model/jpa/src/main/resources/default-persistence.xml +++ b/model/jpa/src/main/resources/default-persistence.xml @@ -46,6 +46,7 @@ org.keycloak.models.jpa.entities.UserConsentEntity org.keycloak.models.jpa.entities.UserConsentClientScopeEntity org.keycloak.models.jpa.entities.UserVerifiableCredentialEntity + org.keycloak.models.jpa.entities.IssuedVerifiableCredentialEntity org.keycloak.models.jpa.entities.AuthenticationFlowEntity org.keycloak.models.jpa.entities.AuthenticationExecutionEntity org.keycloak.models.jpa.entities.AuthenticatorConfigEntity diff --git a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java index 74982942030b..0961c6c4a081 100755 --- a/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -44,6 +44,7 @@ import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.ProtocolMapperModel; @@ -967,6 +968,24 @@ public Stream getVerifiableCredentialsByUser(Stri } } + @Override + public void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc) { + if (StorageId.isLocalStorage(issuedVc.getUserId())) { + localStorage().addIssuedVerifiableCredential(issuedVc); + } else { + throw new UnsupportedOperationException("Issued verifiable credential operations not yet supported on federated users"); + } + } + + @Override + public Stream getIssuedVerifiableCredentialsStreamByUser(String userId) { + if (StorageId.isLocalStorage(userId)) { + return localStorage().getIssuedVerifiableCredentialsStreamByUser(userId); + } else { + throw new UnsupportedOperationException("Issued verifiable credential operations not yet supported on federated users"); + } + } + @Override public void setNotBeforeForUser(RealmModel realm, UserModel user, int notBefore) { if (StorageId.isLocalStorage(user.getId())) { diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 5ce54b016b06..ffb4570df82c 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -69,6 +69,7 @@ import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderQuery; import org.keycloak.models.IdentityProviderType; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.ModelIllegalStateException; @@ -127,6 +128,7 @@ import org.keycloak.representations.idm.authorization.ResourceRepresentation; import org.keycloak.representations.idm.authorization.ResourceServerRepresentation; import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; import org.keycloak.storage.StorageId; import org.keycloak.util.JsonSerialization; @@ -1473,4 +1475,17 @@ public static OrganizationDomainRepresentation toRepresentation(OrganizationDoma representation.setVerified(model.isVerified()); return representation; } + + public static IssuedVerifiableCredentialRepresentation toRepresentation(IssuedVerifiableCredentialModel model) { + IssuedVerifiableCredentialRepresentation rep = new IssuedVerifiableCredentialRepresentation(); + rep.setId(model.getId()); + rep.setUserId(model.getUserId()); + rep.setCredentialType(model.getCredentialType()); + rep.setRevision(model.getRevision()); + rep.setIssuedAt(model.getIssuedAt()); + rep.setExpiresAt(model.getExpiresAt()); + rep.setClientId(model.getClientId()); + return rep; + } + } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 9492dbb9baac..8c4b39ece734 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -80,6 +80,7 @@ import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelException; @@ -130,6 +131,7 @@ import org.keycloak.representations.idm.authorization.ResourceRepresentation; import org.keycloak.representations.idm.authorization.ResourceServerRepresentation; import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; import org.keycloak.storage.DatastoreProvider; import org.keycloak.util.JsonSerialization; @@ -1772,4 +1774,16 @@ public static OrganizationModel toModel(OrganizationRepresentation rep, Organiza public static OrganizationDomainModel toModel(OrganizationDomainRepresentation domainRepresentation) { return new OrganizationDomainModel(domainRepresentation.getName(), domainRepresentation.isVerified()); } + + public static IssuedVerifiableCredentialModel toModel(IssuedVerifiableCredentialRepresentation representation) { + IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(); + model.setId(representation.getId()); + model.setUserId(representation.getUserId()); + model.setCredentialType(representation.getCredentialType()); + model.setIssuedAt(representation.getIssuedAt()); + model.setExpiresAt(representation.getExpiresAt()); + model.setClientId(representation.getClientId()); + model.setRevision(representation.getRevision()); + return model; + } } diff --git a/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java new file mode 100644 index 000000000000..aff884e20840 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/IssuedVerifiableCredentialModel.java @@ -0,0 +1,78 @@ +package org.keycloak.models; + +public class IssuedVerifiableCredentialModel { + + private String id; + private String userId; + private String credentialType; + private Long issuedAt; + private Long expiresAt; + // This represents UUID of the client, which acts as OID4VCI wallet + private String clientId; + private String revision; + + public IssuedVerifiableCredentialModel() { + } + + public IssuedVerifiableCredentialModel(String userId, String credentialType, String clientId) { + this.userId = userId; + this.credentialType = credentialType; + this.clientId = clientId; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getCredentialType() { + return credentialType; + } + + public void setCredentialType(String credentialType) { + this.credentialType = credentialType; + } + + public Long getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(Long issuedAt) { + this.issuedAt = issuedAt; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/UserProvider.java b/server-spi/src/main/java/org/keycloak/models/UserProvider.java index e4f3eca63005..b98a79af7545 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserProvider.java @@ -340,4 +340,20 @@ public interface UserProvider extends Provider, */ UserCredentialManager getUserCredentialManager(UserModel user); + /** + * Record that a verifiable credential was issued to a user. + * + * @param issuedVc model with userId, clientId, credentialType set + * + */ + void addIssuedVerifiableCredential(IssuedVerifiableCredentialModel issuedVc); + + /** + * Get all issued verifiable credentials for a specific user. + * + * @param userId user ID + * @return stream of issued verifiable credentials, sorted by issuedAt descending + */ + Stream getIssuedVerifiableCredentialsStreamByUser(String userId); + } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index a06e26274690..525ab059b246 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -68,6 +68,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.IssuedVerifiableCredentialModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -1022,6 +1023,9 @@ public Response requestCredential(String requestPayload) { eventBuilder.detail(Details.SCOPE, supportedCredential.getScope()) .detail(Details.VERIFIABLE_CREDENTIAL_FORMAT, supportedCredential.getFormat()) .detail(Details.VERIFIABLE_CREDENTIALS_ISSUED, String.valueOf(responseVO.getCredentials().size())); + + recordIssuedVerifiableCredentials(userModel, clientModel, supportedCredential.getScope(), responseVO.getCredentials().size()); + eventBuilder.success(); // Clean up offer state after successful credential issuance @@ -1034,6 +1038,18 @@ public Response requestCredential(String requestPayload) { return response; } + private void recordIssuedVerifiableCredentials(UserModel userModel, ClientModel clientModel, String credentialScope, int count) { + try { + if (count > 0) { + IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userModel.getId(), credentialScope, clientModel.getId()); + session.users().addIssuedVerifiableCredential(model); + LOGGER.debugf("Recorded VC issuance: user=%s, client=%s, type=%s, credentials=%d", userModel.getUsername(), clientModel.getClientId(), credentialScope, count); + } + } catch (Exception e) { + LOGGER.warnf(e, "Failed to record VC issuance for user=%s, client=%s, type=%s", userModel.getUsername(), clientModel.getClientId(), credentialScope); + } + } + private List getAuthorizationDetailsResponse(AccessToken accessToken) { List tokenAuthDetails = accessToken.getAuthorizationDetails(); AuthorizationDetailsProcessor oid4vcProcessor = session.getProvider(AuthorizationDetailsProcessor.class, OPENID_CREDENTIAL); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java index 6816275ac3ad..d1f4cd718bba 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/resources/admin/UserVerifiableCredentialResource.java @@ -28,6 +28,7 @@ import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.representations.idm.ErrorRepresentation; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; @@ -188,6 +189,24 @@ public void revokeCredential(@PathParam("credentialScopeName") String credential adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success(); } + @GET + @Path("issued-credentials") + @Produces(MediaType.APPLICATION_JSON) + @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) + @Operation(summary = "Get issued verifiable credentials for the user") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "OK"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) + public List getIssuedCredentials() { + auth.users().requireView(user); + checkOid4VCIEnabled(); + + return session.users().getIssuedVerifiableCredentialsStreamByUser(user.getId()) + .map(ModelToRepresentation::toRepresentation) + .toList(); + } + private void checkOid4VCIEnabled() { if (!Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) { throw ErrorResponse.error("Feature " + Profile.Feature.OID4VC_VCI.getKey() + " not enabled", Response.Status.BAD_REQUEST); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java index 16695db01a4e..44f9f26ecf9d 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/PermissionsTest.java @@ -428,6 +428,7 @@ public void users() { invoke(realm -> realm.users().get(user.getId()).revokeConsent("testclient"), Resource.USER, true); invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().getCredentials(), Resource.USER, false); + invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().getIssuedCredentials(), Resource.USER, false); UserVerifiableCredentialRepresentation verifCred = new UserVerifiableCredentialRepresentation(); verifCred.setCredentialScopeName("nosuch"); invoke(realm -> realm.users().get(user.getId()).verifiableCredentials().createCredential(verifCred), Resource.USER, true); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java new file mode 100644 index 000000000000..d7ab94d30649 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/IssuedVerifiableCredentialTest.java @@ -0,0 +1,218 @@ +package org.keycloak.tests.admin.user; + +import java.util.List; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; + +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.util.Time; +import org.keycloak.models.IssuedVerifiableCredentialModel; +import org.keycloak.models.UserVerifiableCredentialModel; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; +import org.keycloak.tests.suites.DatabaseTest; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.jwtTypeNaturalPersonScopeName; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.oneOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + + +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) +public class IssuedVerifiableCredentialTest extends AbstractUserTest { + + private static final String CREDENTIAL_TYPE_1 = jwtTypeNaturalPersonScopeName; + private static final String CREDENTIAL_TYPE_2 = "education-cert"; + + @InjectRealm(config = IssuedVcTestRealmConfig.class) + protected ManagedRealm testRealm; + + @Test + @DatabaseTest + public void testGetIssuedCredentials_EmptyList() { + String userId = createUser(); + + UserResource userResource = managedRealm.admin().users().get(userId); + List issuedCreds = userResource.verifiableCredentials().getIssuedCredentials(); + + assertNotNull(issuedCreds, "Should return empty list, not null"); + assertThat(issuedCreds, empty()); + } + + @Test + @DatabaseTest + public void testGetIssuedCredentials_WithData() { + String userId = createUser(); + + createIssuedVcViaModelLayer(userId, CREDENTIAL_TYPE_1, "wallet-123", "rev-001"); + createIssuedVcViaModelLayer(userId, CREDENTIAL_TYPE_2, "wallet-456", "rev-002"); + + // Retrieve via REST API + UserResource userResource = managedRealm.admin().users().get(userId); + List issuedCreds = userResource.verifiableCredentials().getIssuedCredentials(); + + assertThat(issuedCreds, hasSize(2)); + + // Verify field population + IssuedVerifiableCredentialRepresentation firstCred = issuedCreds.get(0); + assertNotNull(firstCred.getId()); + assertEquals(userId, firstCred.getUserId()); + assertThat(firstCred.getCredentialType(), is(oneOf(CREDENTIAL_TYPE_1, CREDENTIAL_TYPE_2))); + assertNotNull(firstCred.getClientId()); + assertNotNull(firstCred.getRevision()); + assertNotNull(firstCred.getIssuedAt()); + } + + @Test + @DatabaseTest + public void testGetIssuedCredentials_SortedByIssuedAtDesc() { + String userId = createUser(); + long baseTime = Time.currentTimeMillis(); + + createIssuedVcViaModelLayer(userId, "cert-1", "wallet-1", "rev-1", baseTime); + createIssuedVcViaModelLayer(userId, "cert-2", "wallet-2", "rev-2", baseTime + 1000); + createIssuedVcViaModelLayer(userId, "cert-3", "wallet-3", "rev-3", baseTime + 2000); + + UserResource userResource = managedRealm.admin().users().get(userId); + List issuedCreds = userResource.verifiableCredentials().getIssuedCredentials(); + + assertThat(issuedCreds, hasSize(3)); + + assertEquals("cert-3", issuedCreds.get(0).getCredentialType()); + assertEquals("cert-2", issuedCreds.get(1).getCredentialType()); + assertEquals("cert-1", issuedCreds.get(2).getCredentialType()); + } + + @Test + @DatabaseTest + public void testGetIssuedCredentials_UserIsolation() { + String user1Id = createUser("user1", "user1@test.com"); + String user2Id = createUser("user2", "user2@test.com"); + + createIssuedVcViaModelLayer(user1Id, "user1-cert", "wallet1", "rev1"); + createIssuedVcViaModelLayer(user2Id, "user2-cert", "wallet2", "rev2"); + + // User 1 should only see their VC + List user1Creds = + managedRealm.admin().users().get(user1Id).verifiableCredentials().getIssuedCredentials(); + + assertThat(user1Creds, hasSize(1)); + assertEquals("user1-cert", user1Creds.get(0).getCredentialType()); + + // User 2 should only see their VC + List user2Creds = + managedRealm.admin().users().get(user2Id).verifiableCredentials().getIssuedCredentials(); + + assertThat(user2Creds, hasSize(1)); + assertEquals("user2-cert", user2Creds.get(0).getCredentialType()); + } + + @Test + @DatabaseTest + public void testGetIssuedCredentials_WithExpiresAt() { + String userId = createUser(); + long issuedAt = Time.currentTimeMillis(); + long expiresAt = issuedAt + 86400000L; // 24 hours later + + runOnServer.run(session -> { + UserVerifiableCredentialModel vcModel = new UserVerifiableCredentialModel(CREDENTIAL_TYPE_1); + vcModel.setRevision("rev-001"); + session.users().addVerifiableCredential(userId, vcModel); + }); + + runOnServer.run(session -> { + IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userId, CREDENTIAL_TYPE_1, "wallet-123"); + model.setRevision("rev-001"); + model.setIssuedAt(issuedAt); + model.setExpiresAt(expiresAt); + session.users().addIssuedVerifiableCredential(model); + }); + + UserResource userResource = managedRealm.admin().users().get(userId); + List issuedCreds = userResource.verifiableCredentials().getIssuedCredentials(); + + assertThat(issuedCreds, hasSize(1)); + IssuedVerifiableCredentialRepresentation cred = issuedCreds.get(0); + assertEquals(expiresAt, cred.getExpiresAt()); + } + + @Test + public void testGetIssuedCredentials_FeatureDisabled() { + managedRealm.updateWithCleanup((realm) -> realm.verifiableCredentialsEnabled(false)); + adminEvents.clear(); + + String userId = createUser(); + UserResource userResource = managedRealm.admin().users().get(userId); + + try { + userResource.verifiableCredentials().getIssuedCredentials(); + Assertions.fail("Expected BadRequestException when feature is disabled"); + } catch (BadRequestException e) { + assertThat(e.getResponse().getStatus(), is(400)); + } + } + + @Test + @DatabaseTest + public void testGetIssuedCredentials_NonExistentUser() { + try { + managedRealm.admin().users().get("non-existent-user-id").verifiableCredentials().getIssuedCredentials(); + Assertions.fail("Expected NotFoundException"); + } catch (NotFoundException e) { + assertThat(e.getResponse().getStatus(), is(404)); + } + } + + // Helper methods + + protected void createIssuedVcViaModelLayer(String userId, String credentialType, + String clientId, String revision) { + createIssuedVcViaModelLayer(userId, credentialType, clientId, revision, null); + } + + protected void createIssuedVcViaModelLayer(String userId, String credentialType, + String clientId, String revision, Long issuedAt) { + runOnServer.run(session -> { + UserVerifiableCredentialModel vcModel = new UserVerifiableCredentialModel(credentialType); + vcModel.setRevision(revision); + session.users().addVerifiableCredential(userId, vcModel); + }); + + runOnServer.run(session -> { + IssuedVerifiableCredentialModel model = new IssuedVerifiableCredentialModel(userId, credentialType, clientId); + model.setRevision(revision); + if (issuedAt != null) { + model.setIssuedAt(issuedAt); + } + session.users().addIssuedVerifiableCredential(model); + }); + } + + public static class IssuedVcTestRealmConfig implements RealmConfig { + public static final String TEST_REALM_NAME = "test"; + + @Override + public RealmBuilder configure(RealmBuilder realm) { + return realm + .name(TEST_REALM_NAME) + .eventsEnabled(true) + .eventsListeners("jboss-logging") + .verifiableCredentialsEnabled(true); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java new file mode 100644 index 000000000000..d94145a6e857 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuedCredentialsAdminAPITest.java @@ -0,0 +1,105 @@ +package org.keycloak.tests.oid4vc; + +import java.util.List; + +import org.keycloak.admin.client.resource.UserVerifiableCredentialResource; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +import org.keycloak.protocol.oid4vc.model.Proofs; +import org.keycloak.representations.idm.oid4vc.IssuedVerifiableCredentialRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; +import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithRestCredentialOfferEnabled.class) +public class OID4VCIssuedCredentialsAdminAPITest extends OID4VCIssuerEndpointTest { + + @Test + public void testIssuedCredentialsArePersistedAndRetrievableViaAdminAPI() { + String scopeName = jwtTypeCredentialScope.getName(); + String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + String userId = testRealm.admin().users().search("john").get(0).getId(); + + UserVerifiableCredentialResource userVerifiableCredentialResource = testRealm.admin().users().get(userId).verifiableCredentials(); + + List initialIssuedCreds = userVerifiableCredentialResource.getIssuedCredentials(); + assertTrue(initialIssuedCreds.isEmpty(), "No issued credentials should exist initially"); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + + String cNonce = oauth.oid4vc().nonceRequest().send().getNonce(); + Proofs proofs = jwtProofs(credentialIssuer.getCredentialIssuer(), cNonce); + + CredentialResponse credentialResponseVO = oauth.oid4vc() + .credentialRequest() + .bearerToken(token) + .credentialIdentifier(credentialIdentifier) + .proofs(proofs) + .send() + .getCredentialResponse(); + + assertNotNull(credentialResponseVO, "Credential should have been issued"); + assertNotNull(credentialResponseVO.getCredentials(), "Credentials array should not be null"); + assertFalse(credentialResponseVO.getCredentials().isEmpty(), "At least one credential should be issued"); + + List issuedCreds = testRealm.admin().users().get(userId).verifiableCredentials().getIssuedCredentials(); + + assertEquals(1, issuedCreds.size(), "Exactly one issued credential should be stored"); + + IssuedVerifiableCredentialRepresentation issuedCred = issuedCreds.get(0); + + assertAll( + () -> assertNotNull(issuedCred.getId(), "Issued credential should have an ID"), + () -> assertEquals(userId, issuedCred.getUserId(), "User ID should match"), + () -> assertEquals(scopeName, issuedCred.getCredentialType(), "Credential type should match the scope name"), + () -> assertNotNull(issuedCred.getRevision(), "Revision should be set"), + () -> assertNotNull(issuedCred.getIssuedAt(), "IssuedAt timestamp should be set"), + () -> assertNotNull(issuedCred.getClientId(), "ClientId should be set") + ); + + String cNonce2 = oauth.oid4vc().doNonceRequest().getNonce(); + Proofs proofs2 = jwtProofs(credentialIssuer.getCredentialIssuer(), cNonce2); + + CredentialResponse credentialResponseVO2 = oauth.oid4vc() + .credentialRequest() + .bearerToken(token) + .credentialIdentifier(credentialIdentifier) + .proofs(proofs2) + .send() + .getCredentialResponse(); + + assertNotNull(credentialResponseVO2, "Second credential should have been issued"); + + List multipleIssuedCreds = testRealm.admin().users().get(userId).verifiableCredentials().getIssuedCredentials(); + + assertEquals(2, multipleIssuedCreds.size(), "Two issued credentials should be stored"); + + // Verify sorting (newest first - DESC by issuedAt) + assertTrue(multipleIssuedCreds.get(0).getIssuedAt() >= multipleIssuedCreds.get(1).getIssuedAt(), + "Issued credentials should be sorted by issuedAt DESC (newest first)"); + } +}