Use back keycloak-js instead of initiate login in the backend for account (#42035)

Closes #40463

Signed-off-by: rmartinc <rmartinc@redhat.com>
(cherry picked from commit 360ff7050c290939d529e68b461ba61c7c11404a)
This commit is contained in:
Ricardo Martin 2025-08-26 16:29:46 +02:00 committed by Marek Posolda
parent 3ff08efd66
commit 9804b53f3b
11 changed files with 144 additions and 211 deletions

View File

@ -215,4 +215,22 @@ organizationList=List of organizations
domains=Domains
refresh=Refresh
termsAndConditionsDeclined=You need to accept the Terms and Conditions to continue
defaultLocale=Choose...
defaultLocale=Choose...
# standard error responses OAuth
invalid_request=Invalid request
unauthorized_client=Unauthorized client
access_denied=Access denied
unsupported_response_type=Unsupported response type
invalid_scope=Invalid scope
server_error=Server error
temporarily_unavailable=Temporarily unavailable
# standard error responses OIDC
interaction_required=Interaction required
login_required=Login required
account_selection_required=Account selection required
consent_required=Consent required
invalid_request_uri=Invalid request uri
invalid_request_object=Invalid request object
request_not_supported=Request not supported
request_uri_not_supported=Request uri not supported
registration_not_supported=Registration not supported

View File

@ -3502,3 +3502,21 @@ givenNameClaim=Given name Claim
givenNameClaimHelp=The name of the claim from the JSON document returned by the user profile endpoint representing the user's given name. If not provided, defaults to `given_name`.
familyNameClaim=Family name Claim
familyNameClaimHelp=The name of the claim from the JSON document returned by the user profile endpoint representing the user's family name. If not provided, defaults to `family_name`.
# standard error responses OAuth
invalid_request=Invalid request
unauthorized_client=Unauthorized client
access_denied=Access denied
unsupported_response_type=Unsupported response type
invalid_scope=Invalid scope
server_error=Server error
temporarily_unavailable=Temporarily unavailable
# standard error responses OIDC
interaction_required=Interaction required
login_required=Login required
account_selection_required=Account selection required
consent_required=Consent required
invalid_request_uri=Invalid request uri
invalid_request_object=Invalid request object
request_not_supported=Request not supported
request_uri_not_supported=Request uri not supported
registration_not_supported=Registration not supported

View File

@ -7,18 +7,17 @@ import {
TextContent,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { getNetworkErrorDescription } from "../utils/errors";
import { getNetworkErrorMessage } from "../utils/errors";
type ErrorPageProps = {
error?: unknown;
};
export const ErrorPage = (props: ErrorPageProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const error = props.error;
const errorMessage =
getErrorMessage(error) ||
getNetworkErrorDescription(error)?.replace(/\+/g, " ");
const errorMessage = getErrorMessage(error);
const networkErrorMessage = getNetworkErrorMessage(error);
console.error(error);
function onRetry() {
@ -29,7 +28,7 @@ export const ErrorPage = (props: ErrorPageProps) => {
<Page>
<Modal
variant={ModalVariant.small}
title={errorMessage ? "" : t("somethingWentWrong")}
title={t("somethingWentWrong")}
titleIconVariant="danger"
showClose={false}
isOpen
@ -42,6 +41,8 @@ export const ErrorPage = (props: ErrorPageProps) => {
<TextContent>
{errorMessage ? (
<Text>{t(errorMessage)}</Text>
) : networkErrorMessage && i18n.exists(networkErrorMessage) ? (
<Text>{t(networkErrorMessage)}</Text>
) : (
<Text>{t("somethingWentWrongDescription")}</Text>
)}
@ -52,10 +53,6 @@ export const ErrorPage = (props: ErrorPageProps) => {
};
function getErrorMessage(error: unknown): string | null {
if (typeof error === "string") {
return error;
}
if (error instanceof Error) {
return error.message;
}

View File

@ -67,7 +67,7 @@ export const KeycloakProvider = <T extends BaseEnvironment>({
const init = () =>
keycloak.init({
onLoad: "check-sso",
onLoad: "login-required",
pkceMethod: "S256",
responseMode: "query",
scope: environment.scope,
@ -80,14 +80,8 @@ export const KeycloakProvider = <T extends BaseEnvironment>({
calledOnce.current = true;
}, [keycloak]);
const searchParams = new URLSearchParams(window.location.search);
if (error || searchParams.get("error_description")) {
return (
<ErrorPage
error={error ? error : searchParams.get("error_description")}
/>
);
if (error) {
return <ErrorPage error={error} />;
}
if (!init) {

View File

@ -3,13 +3,9 @@ package org.keycloak.services.resources.account;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.ServerErrorException;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
@ -18,7 +14,6 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderStorageProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.utils.SecureContextResolver;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
@ -28,7 +23,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
@ -46,18 +40,15 @@ import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.urls.UrlType;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import org.keycloak.utils.StringUtil;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Scanner;
import java.util.UUID;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -110,10 +101,6 @@ public class AccountConsole implements AccountResourceProvider {
@Path("{path:.*}")
public Response getMainPage(@PathParam("path") String path) throws IOException, FreeMarkerException {
if (auth == null) {
return redirectToLogin(path);
}
return renderAccountConsole();
}
@ -232,77 +219,6 @@ public class AccountConsole implements AccountResourceProvider {
return builder.build();
}
private Response redirectToLogin(String path) {
UriBuilder consoleUriBuilder = Urls.accountBase(session.getContext().getUri().getBaseUri());
if (!StringUtil.isNullOrEmpty(path)) {
consoleUriBuilder.path(path);
}
var queryParameters = session.getContext().getUri().getQueryParameters();
if (!queryParameters.isEmpty()) {
String referrer = queryParameters.getFirst("referrer");
if (referrer != null) {
consoleUriBuilder.queryParam("referrer", referrer);
}
String referrerUri = queryParameters.getFirst("referrer_uri");
if (referrerUri != null) {
consoleUriBuilder.queryParam("referrer_uri", referrerUri);
}
}
URI targetUri = consoleUriBuilder.build(realm.getName());
String pkceChallenge;
try {
// Add PKCE parameters as it is required for the account-console client.
// Because the account console configuration requires PKCE, we need to send this with the redirect in order to not fail validations.
// The real PKCE challenge will be sent by the account-console OIDC client JavaScript integration.
String codeVerifier = UUID.randomUUID().toString();
pkceChallenge = PkceUtils.generateS256CodeChallenge(codeVerifier);
} catch (Exception e) {
// this should never happen
throw new RuntimeException(e);
}
UriBuilder uriBuilder = UriBuilder.fromUri(OIDCLoginProtocolService.authUrl(session.getContext().getUri()).build(realm.getName()).toString())
.queryParam(OAuth2Constants.CLIENT_ID, Constants.ACCOUNT_CONSOLE_CLIENT_ID)
.queryParam(OAuth2Constants.REDIRECT_URI, targetUri)
// dummy state param to make it usable with secure-session client policy.
// Once bootstrapped the account-console frontend will send the actual state with the authorize request.
.queryParam(OAuth2Constants.STATE, UUID.randomUUID().toString())
.queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE)
.queryParam(OAuth2Constants.CODE_CHALLENGE, pkceChallenge)
.queryParam(OAuth2Constants.CODE_CHALLENGE_METHOD, OAuth2Constants.PKCE_METHOD_S256);
if (!queryParameters.isEmpty()) {
String error = queryParameters.getFirst(OAuth2Constants.ERROR);
if (error != null) {
String state = queryParameters.getFirst(OAuth2Constants.STATE);
if (state != null) {
// Omit the "state" parameter to make sure that account console displays the error (it may not be shown due the keycloak.js, which will not be able to find the "callback data" in the browser callbackStorage)
URI url = session.getContext().getUri(UrlType.FRONTEND)
.getRequestUriBuilder()
.replaceQueryParam(OAuth2Constants.STATE, null)
.build();
return Response.status(302).location(url).build();
} else {
try {
return renderAccountConsole();
} catch (IOException | FreeMarkerException e) {
throw new ServerErrorException(Status.INTERNAL_SERVER_ERROR);
}
}
}
String scope = queryParameters.getFirst(OIDCLoginProtocol.SCOPE_PARAM);
if (StringUtil.isNotBlank(scope)) {
uriBuilder.queryParam(OIDCLoginProtocol.SCOPE_PARAM, scope);
}
}
URI url = uriBuilder.build();
return Response.status(302).location(url).build();
}
private Map<String, String> supportedLocales(Properties messages) {
return realm.getSupportedLocalesStream()
.collect(Collectors.toMap(Function.identity(), l -> messages.getProperty("locale_" + l, l)));

View File

@ -1,102 +0,0 @@
package org.keycloak.testsuite.account;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.pages.LoginPage;
public class AccountConsoleTest extends AbstractTestRealmKeycloakTest {
@Page
protected LoginPage loginPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void redirectToLoginIfNotAuthenticated() {
assertRedirectLocation(getAccount());
}
@Test
public void testScopesPresentInAuthorizationRequest() {
String expectedScopes = "phone address";
String redirectLocation = URLDecoder.decode(assertRedirectLocation(getAccount(expectedScopes)));
Assert.assertTrue(redirectLocation.contains(expectedScopes));
expectedScopes = "phone";
redirectLocation = URLDecoder.decode(assertRedirectLocation(getAccount(expectedScopes)));
Assert.assertTrue(redirectLocation.contains(expectedScopes));
Assert.assertTrue(!redirectLocation.contains("address"));
// should render the account with the phone scope
driver.navigate().to(redirectLocation);
loginPage.login("test-user@localhost", "password");
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should render the account with the address scope only
expectedScopes = "address";
redirectLocation = URLDecoder.decode(assertRedirectLocation(getAccount(expectedScopes)));
driver.navigate().to(redirectLocation);
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should render the account with the phone and address scopes
expectedScopes = "phone address";
redirectLocation = URLDecoder.decode(assertRedirectLocation(getAccount(expectedScopes)));
driver.navigate().to(redirectLocation);
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should keep previously requested scopes when not setting the scope parameter
redirectLocation = URLDecoder.decode(assertRedirectLocation(getAccount()));
driver.navigate().to(redirectLocation);
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
}
private CloseableHttpResponse getAccount() {
return getAccount(null);
}
private CloseableHttpResponse getAccount(String scope) {
try {
var uriBuilder = new URIBuilder(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account");
if (scope != null) {
uriBuilder.setParameter(OIDCLoginProtocol.SCOPE_PARAM, scope);
}
var request = new HttpGet(uriBuilder.build());
try (var client = HttpClientBuilder.create().disableRedirectHandling().build()) {
return client.execute(request);
}
} catch (URISyntaxException | IOException e) {
throw new RuntimeException(e);
}
}
private String assertRedirectLocation(CloseableHttpResponse Account) {
try (var response = Account) {
int statusCode = response.getStatusLine().getStatusCode();
Assert.assertEquals(302, statusCode);
String expectedLoginUrlPart = "/realms/" + oauth.getRealm() + "/protocol/openid-connect/auth?client_id=" + Constants.ACCOUNT_CONSOLE_CLIENT_ID;
String redirectLocation = response.getFirstHeader("Location").getValue();
Assert.assertTrue(redirectLocation.contains(expectedLoginUrlPart));
return redirectLocation;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -187,7 +187,7 @@ public class TermsAndConditionsTest extends AbstractChangeImportedUserPasswordsT
attributes.get(TermsAndConditions.USER_ATTRIBUTE));
}
assertThat(DroneUtils.getCurrentDriver().getTitle(), equalTo("Account Management"));
Assert.assertTrue(DroneUtils.getCurrentDriver().getPageSource().contains("You need to accept the Terms and Conditions to continue"));
Assert.assertTrue(DroneUtils.getCurrentDriver().getPageSource().contains("Access denied"));
Assert.assertFalse(DroneUtils.getCurrentDriver().getPageSource().contains("An unexpected error occurred"));
WebElement tryAgainButton = DroneUtils.getCurrentDriver().findElement(By.tagName("button"));

View File

@ -345,7 +345,7 @@ public abstract class AbstractBaseBrokerTest extends AbstractKeycloakTest {
// Completely logout from realm and confirm logout if present
protected void logoutFromRealm(String contextRoot, String realm, String initiatingIdp, String idTokenHint, String clientId, String redirectUri) {
final String defaultRedirectUri = redirectUri != null ? redirectUri : getAccountUrl(contextRoot, realm);
final String defaultRedirectUri = redirectUri != null ? redirectUri : oauth.loginForm().build();
final String defaultClientId = (idTokenHint == null && clientId == null) ? "test-app" : clientId;
executeLogoutFromRealm(contextRoot, realm, initiatingIdp, idTokenHint, defaultClientId, defaultRedirectUri);

View File

@ -73,6 +73,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AuthorizationResponseToken;
import org.keycloak.representations.IDToken;
@ -115,7 +116,9 @@ import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.testsuite.util.oauth.ParResponse;
import org.keycloak.testsuite.util.oauth.PkceGenerator;
import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.util.JsonSerialization;
@ -933,9 +936,16 @@ public class ClientPoliciesExecutorTest extends AbstractClientPoliciesTest {
updatePolicies(json);
// Test account-console is loaded successfully when "secure-session-enforce" executor is present
appPage.open();
appPage.openAccount();
oauth.client(Constants.ACCOUNT_CONSOLE_CLIENT_ID)
.redirectUri(OAuthClient.AUTH_SERVER_ROOT + "/realms/test/account/")
.responseMode(OIDCResponseMode.QUERY.value())
.loginForm()
.state(KeycloakModelUtils.generateId())
.nonce(KeycloakModelUtils.generateId())
.codeChallenge(PkceGenerator.s256())
.open();
loginPage.assertCurrent();
Assert.assertEquals("Sign in to your account", loginPage.getTitleText());
}
@Test

View File

@ -0,0 +1,82 @@
package org.keycloak.testsuite.forms;
import java.net.URISyntaxException;
import org.apache.http.client.utils.URIBuilder;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.testsuite.AbstractChangeImportedUserPasswordsTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
// real browser needed as it needs the account console and keycloak-js
@IgnoreBrowserDriver(value={ChromeDriver.class, FirefoxDriver.class}, negate=true)
public class AccountConsoleTest extends AbstractChangeImportedUserPasswordsTest {
@Page
protected LoginPage loginPage;
@Test
public void redirectToLoginIfNotAuthenticated() {
driver.navigate().to(getAccount());
WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
Assert.assertTrue(driver.getCurrentUrl().contains("client_id=" + Constants.ACCOUNT_CONSOLE_CLIENT_ID));
}
@Test
public void testScopesPresentInAuthorizationRequest() {
String expectedScopes = "openid phone";
String redirectLocation = getAccount(expectedScopes);
// should render the account with the phone scope
driver.navigate().to(redirectLocation);
WaitUtils.waitForPageToLoad();
loginPage.login("test-user@localhost", getPassword("test-user@localhost"));
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should render the account with the address scope only
expectedScopes = "openid address";
redirectLocation = getAccount(expectedScopes);
driver.navigate().to(redirectLocation);
WaitUtils.waitForPageToLoad();
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should render the account with the phone and address scopes
expectedScopes = "openid phone address";
redirectLocation = getAccount(expectedScopes);
driver.navigate().to(redirectLocation);
WaitUtils.waitForPageToLoad();
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
// should keep previously requested scopes when not setting the scope parameter
redirectLocation = getAccount();
driver.navigate().to(redirectLocation);
WaitUtils.waitForPageToLoad();
Assert.assertTrue(driver.getPageSource().contains("\"scope\": \"" + expectedScopes + "\""));
}
private String getAccount() {
return getAccount(null);
}
private String getAccount(String scope) {
try {
var uriBuilder = new URIBuilder(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account");
if (scope != null) {
uriBuilder.setParameter(OIDCLoginProtocol.SCOPE_PARAM, scope);
}
return uriBuilder.build().toString();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -424,7 +424,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
}
private void acceptInvitation(OrganizationResource organization, UserRepresentation user) throws MessagingException, IOException {
acceptInvitation(organization, user, "Sign in to");
acceptInvitation(organization, user, "Account Management");
}
private void acceptInvitation(OrganizationResource organization, UserRepresentation user, String pageTitle) throws MessagingException, IOException {