Skip to content

Show a confirmation page when email is verified in another tab - #51265

Open
Isaac-McClure wants to merge 1 commit into
keycloak:mainfrom
Isaac-McClure:iss43896
Open

Show a confirmation page when email is verified in another tab#51265
Isaac-McClure wants to merge 1 commit into
keycloak:mainfrom
Isaac-McClure:iss43896

Conversation

@Isaac-McClure

@Isaac-McClure Isaac-McClure commented Jul 29, 2026

Copy link
Copy Markdown

During registration with email verification, the tab left showing the verify-email page was deleted along with its authentication session when the user verified their email elsewhere. Refreshing that tab failed with an expired code, and its session polling navigated into the authentication flow, which then completed and redirected to the client instead of confirming anything.

The registration authentication session is now preserved, and the verify-email page polls a standalone verify-email-success endpoint that renders a confirmation without re-entering the authentication flow.

Closes #43896

This PR was made using generative AI.

Screenshot from the email redirect success page after email was verified in another tab:

image

Copilot AI review requested due to automatic review settings July 29, 2026 11:18
@Isaac-McClure
Isaac-McClure requested review from a team as code owners July 29, 2026 11:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a terminal email-verification confirmation page for registration completed in another tab.

Changes:

  • Preserves registration authentication sessions after external verification.
  • Adds standalone success-page routing and polling.
  • Adds UI integration coverage and page objects.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
themes/.../keycloak.v2/login/template.ftl Configures and suppresses session polling.
themes/.../base/login/template.ftl Configures and suppresses session polling.
themes/.../login-verify-email-success.ftl Adds the confirmation page.
tests/.../RegisterWithEmailVerificationTest.java Tests verification flows and rendering.
test-framework/.../VerifyEmailSuccessPage.java Adds the success-page object.
services/.../Urls.java Builds the success endpoint URL.
services/.../LoginActionsService.java Exposes the standalone endpoint.
services/.../Templates.java Maps the new page template.
services/.../model/UrlBean.java Exposes the endpoint to themes.
services/.../FreeMarkerLoginFormsProvider.java Renders the terminal page.
services/.../VerifyEmail.java Directs registration polling to confirmation.
services/.../VerifyEmailActionTokenHandler.java Preserves registration sessions.
server-spi-private/.../LoginFormsProvider.java Adds the rendering API.
server-spi-private/.../LoginFormsPages.java Adds the new page type.

Comment on lines +140 to +142
if ("true".equals(authSession.getAuthNote(NEW_USER_REGISTERED))) {
loginFormsProvider.setAttribute("pollToVerifyEmailSuccess", Boolean.TRUE);
loginFormsProvider.setAttribute("skipCheckAuthSession", Boolean.TRUE);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. The page now carries a short-lived VerifyEmailSuccessToken, signed with the realm key via
session.tokens() (same mechanism as DetachedInfoStateCookie), naming the registering user.
The endpoint verifies the signature, checks expiry, loads that user and re-checks
isEmailVerified() before rendering anything. Its lifespan is bounded by
getActionTokenGeneratedByUserLifespan(VERIFY_EMAIL), so it lasts exactly as long as the
verification email it accompanies.

Anything that doesn't validate — no token, bad signature, expired, or a user whose email is
still unverified — falls through to the restart page, which is where the verify-email page's
polling pointed before this change. Added tests for a direct request and for a pending
registration whose email is never verified.

Comment on lines +470 to +473
// ── Browser 2 (new tab): open the verification link ──
MimeMessage message = mailServer.getLastReceivedMessage();
String verifyLink = MailUtils.getPasswordResetEmailLink(message);
driver2.open(verifyLink);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added registerWithEmailVerification_originalTabIsConfirmedAfterVerificationInSameBrowserTab
using a second tab on the same driver.

Copilot AI review requested due to automatic review settings July 29, 2026 13:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java:157

  • The polling override is only configured in process(). The cooldown path in processAction() returns a freshly rendered verify-email page directly (lines 182–188), so clicking resend before the cooldown expires restores the default polling/check-auth scripts and can reproduce the redirect/error this PR fixes. Configure these attributes through a shared helper that is also applied to that retry response.
        if ("true".equals(authSession.getAuthNote(NEW_USER_REGISTERED))) {
            KeycloakSession session = context.getSession();
            RealmModel realm = context.getRealm();

            // Bounded by the same lifespan as the verification email itself: while that link can still be used,
            // the tab waiting on it can still be told that it worked.
            int expiration = Time.currentTime()
                    + realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);
            String tokenString = session.tokens()
                    .encode(new VerifyEmailSuccessToken(context.getUser().getId(), expiration));

            String pollingUrl = Urls.loginActionsVerifyEmailSuccess(context.getUriInfo().getBaseUri(),
                    realm.getName(), tokenString, authSession.getClient().getClientId(), authSession.getTabId(),
                    AuthenticationProcessor.getClientData(session, authSession)).toString();

            loginFormsProvider.setAttribute("sessionPollingUrl", pollingUrl);
            loginFormsProvider.setAttribute("skipCheckAuthSession", Boolean.TRUE);

tests/base/src/test/java/org/keycloak/tests/forms/RegisterWithEmailVerificationTest.java:482

  • This comment now contradicts the production change: registration sessions are deliberately preserved in VerifyEmailActionTokenHandler, not deleted. Update the test explanation so it documents the behavior actually under test.
            // Click the confirm button to actually verify the email. This is the step that:
            //   (a) marks the user's email as verified
            //   (b) DELETES the original auth session that browser 1 is holding

Copilot AI review requested due to automatic review settings July 29, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java:175

  • This recomputes now + lifespan every time the verify-email page is rendered, including ordinary refreshes and cooldown responses, so the polling token can outlive the verification link it accompanies. Persist and reuse the absolute expiry created for the current verification email (and replace it only when a new email is sent) instead of extending it on each render.
        // Bounded by the same lifespan as the verification email itself: while that link can still be used, the
        // tab waiting on it can still be told that it worked.
        int expiration = Time.currentTime()
                + realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);

server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java:82

  • Adding this as an abstract interface method breaks existing custom LoginFormsProvider implementations: after an upgrade, this flow invokes a method their compiled classes do not implement and fails with AbstractMethodError. Provide a default implementation using the existing setAttribute/createForm API (or avoid expanding the SPI) so existing providers remain usable.
    Response createVerifyEmailSuccessPage();

Copilot AI review requested due to automatic review settings July 29, 2026 22:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

Switching languages on the detached success page drops its signed query parameters and redirects away from the confirmation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Comments suppressed due to low confidence (1)

services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java:348

  • For this detached page, the locale URL builder falls through to getDefaultPageUriForLocale(), which does not preserve the request query. With internationalization enabled, selecting another language therefore drops the signed key (plus client/tab data), and verifyEmailSuccess() redirects away instead of rendering the confirmation; add a LOGIN_VERIFY_EMAIL_SUCCESS locale case that preserves the current request URI/query and cover the language switch.
            case LOGIN_VERIFY_EMAIL_SUCCESS:
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

* URL the login page's session polling navigates to when a session appears elsewhere, overriding the default
* "logged in on another tab" target.
*/
String SESSION_POLLING_URL = "sessionPollingUrl";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's just me but this seems to me not like a great name. It's not a url we use for polling, but rather a redirect url

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think you are correct. I have updated it to SESSION_POLLING_REDIRECT_URL.

Copilot AI review requested due to automatic review settings July 30, 2026 11:29
@Isaac-McClure
Isaac-McClure requested review from a team as code owners July 30, 2026 11:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

Polling can retain an obsolete expiry when the registration email changes and a fresh verification link is issued.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Comments suppressed due to low confidence (1)

services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java:202

  • The expiry is reused solely because the note exists. If the user's email changes while this page is open, the mismatch at line 142 sends a fresh verification link, but polling keeps the old expiry and can fail before that new link expires; only reuse the expiry when VERIFY_EMAIL_KEY still matches the current email.
        String existing = authSession.getAuthNote(VERIFY_EMAIL_POLLING_EXPIRATION);
        if (existing != null) {
            return Integer.parseInt(existing);
  • Files reviewed: 16/16 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 1, 2026 08:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

Invalid signed tokens without a user ID can currently cause the public endpoint to return a server error.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 1
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +346 to +348
UserModel user = token == null || token.isExpired()
? null
: session.users().getUserById(realm, token.getUserId());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added guard and verifyEmailSuccessEndpointRejectsSignedTokenWithoutUserId test

During registration with email verification, the tab left showing the verify-email page had no way to report success. When the email was verified in a different browser its authentication session was removed, so refreshing failed with an expired code. When it was verified in another tab of the same browser, both tabs shared one authentication session and completing that login consumed it, leaving the original tab on an error page. In both cases session polling navigated back into the authentication flow, which - with no required action left to run - completed and redirected to the client instead of confirming anything.

The original authentication session is now kept when it belongs to a registration, and the verify-email page polls a dedicated verify-email-success endpoint instead. Polling fires as soon as any session cookie appears and cannot tell what completed, so the page carries a short-lived token, signed with the realm key, naming the registering user. The endpoint verifies that token and re-checks the user's verified status before confirming, and otherwise falls back to the previous restart behaviour.

Closes keycloak#43896

Signed-off-by: isaac mcclure <isaac.mcclure.3@gmail.com>
Copilot AI review requested due to automatic review settings August 1, 2026 08:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Human review recommended

It changes security-sensitive authentication-session and signed-token behavior across multiple server and UI layers.

Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RESTART_AUTHENTICATION_ERROR on register flow with email account verification

3 participants