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 077aa8b19c
commit a61f1d90be
12 changed files with 137 additions and 196 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

@ -3477,3 +3477,21 @@ authTokenClientSecret=Auth Token Client Secret
enableDebugSMTP=Enable Debug SMTP
signatureMaxExp=Max expiration
signatureMaxExpHelp=Maximum expiration allowed for the JWT. Tokens need to be generated right before authentication. After this period will be considered invalid because they are too old. If undefined the default value is 60 seconds.
# 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;
@ -113,10 +104,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();
}
@ -235,64 +222,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)
.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) {
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

@ -339,7 +339,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

@ -72,6 +72,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;
@ -114,7 +115,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;

View File

@ -639,6 +639,7 @@ public class LDAPGroupMapperTest extends AbstractLDAPTest {
LDAPTestContext ctx = LDAPTestContext.init(session);
LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel());
LDAPTestUtils.removeLDAPUserByUsername(ctx.getLdapProvider(), ctx.getRealm(), ldapFedProvider.getLdapIdentityStore().getConfig(), "alice");
Assert.assertNull(session.users().getUserByUsername(ctx.getRealm(), "alice"));
});
}
@ -680,6 +681,7 @@ public class LDAPGroupMapperTest extends AbstractLDAPTest {
LDAPTestContext ctx = LDAPTestContext.init(session);
LDAPStorageProvider ldapFedProvider = LDAPTestUtils.getLdapProvider(session, ctx.getLdapModel());
LDAPTestUtils.removeLDAPUserByUsername(ctx.getLdapProvider(), ctx.getRealm(), ldapFedProvider.getLdapIdentityStore().getConfig(), "alice");
Assert.assertNull(session.users().getUserByUsername(ctx.getRealm(), "alice"));
});
}

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 {