Show a confirmation page when email is verified in another tab - #51265
Show a confirmation page when email is verified in another tab#51265Isaac-McClure wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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. |
| if ("true".equals(authSession.getAuthNote(NEW_USER_REGISTERED))) { | ||
| loginFormsProvider.setAttribute("pollToVerifyEmailSuccess", Boolean.TRUE); | ||
| loginFormsProvider.setAttribute("skipCheckAuthSession", Boolean.TRUE); |
There was a problem hiding this comment.
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.
| // ── Browser 2 (new tab): open the verification link ── | ||
| MimeMessage message = mailServer.getLastReceivedMessage(); | ||
| String verifyLink = MailUtils.getPasswordResetEmailLink(message); | ||
| driver2.open(verifyLink); |
There was a problem hiding this comment.
Added registerWithEmailVerification_originalTabIsConfirmedAfterVerificationInSameBrowserTab
using a second tab on the same driver.
There was a problem hiding this comment.
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 inprocessAction()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
There was a problem hiding this comment.
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 + lifespanevery 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
LoginFormsProviderimplementations: after an upgrade, this flow invokes a method their compiled classes do not implement and fails withAbstractMethodError. Provide a default implementation using the existingsetAttribute/createFormAPI (or avoid expanding the SPI) so existing providers remain usable.
Response createVerifyEmailSuccessPage();
There was a problem hiding this comment.
🟡 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 signedkey(plus client/tab data), andverifyEmailSuccess()redirects away instead of rendering the confirmation; add aLOGIN_VERIFY_EMAIL_SUCCESSlocale 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"; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Thanks, I think you are correct. I have updated it to SESSION_POLLING_REDIRECT_URL.
There was a problem hiding this comment.
🟡 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_KEYstill 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.
There was a problem hiding this comment.
🟡 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.
| UserModel user = token == null || token.isExpired() | ||
| ? null | ||
| : session.users().getUserById(realm, token.getUserId()); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
🟡 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.
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: