diff --git a/README.md b/README.md index 420ecdb..94393a3 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,6 @@ instead, use a Gradle composite build: - [ ] Document playwright usage - [ ] Show login errors on login screen - [ ] Add playwright traces https://playwright.dev/java/docs/trace-viewer-intro -- [ ] Log out +- [x] Log out - [ ] Add example with Ory Cloud diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplication.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplication.java index edea89b..9829196 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplication.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplication.java @@ -18,7 +18,12 @@ public static void main(String[] args) { SecurityFilterChain securityFilterChain(HttpSecurity http) { // Don't do this in production. This was removed to simplify this reference implementation. http.csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + // Spring Security's built-in LogoutFilter intercepts /logout before it can reach any + // controller (with CSRF disabled, even on GET) and redirects to its own /login?logout. + // This app's /logout is Hydra's logout challenge endpoint, not an app-session logout, + // so the filter must be disabled for LogoutController to receive the request. + .logout(AbstractHttpConfigurer::disable); return http.build(); } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java index 2d950d8..96b8d1a 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java @@ -113,6 +113,44 @@ public OAuth2RedirectTo rejectConsentRequest(@NonNull RejectConsentRequest rejec } } + public Optional getLogoutRequest(@NonNull String logoutChallenge) { + try { + return Optional.of(oAuth2Api().getOAuth2LogoutRequest(logoutChallenge)); + } catch (ApiException e) { + return switch (e.getCode()) { + case 410 -> Optional.empty(); // requestWasHandledResponse + case 404 -> Optional.empty(); // jsonError + default -> throw new RuntimeException("unhandled code: " + e.getCode(), e); + }; + } + } + + public OAuth2RedirectTo acceptLogoutRequest(@NonNull String logoutChallenge) { + try { + return oAuth2Api().acceptOAuth2LogoutRequest(logoutChallenge); + } catch (ApiException e) { + switch (e.getCode()) { + case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError + default -> throw new RuntimeException("unhandled code: " + e.getCode(), e); + } + } + } + + /** + * Unlike a rejected login or consent, a rejected logout has no redirect: Hydra returns 204 and + * the session stays alive. Where to send the user afterwards is this app's choice. + */ + public void rejectLogoutRequest(@NonNull String logoutChallenge) { + try { + oAuth2Api().rejectOAuth2LogoutRequest(logoutChallenge); + } catch (ApiException e) { + switch (e.getCode()) { + case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError + default -> throw new RuntimeException("unhandled code: " + e.getCode(), e); + } + } + } + @Data @org.springframework.context.annotation.Configuration @ConfigurationProperties("reference-app.hydra") diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutController.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutController.java new file mode 100644 index 0000000..6f9e26f --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutController.java @@ -0,0 +1,45 @@ +package com.ardetrick.oryhydrareference.logout; + +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; + +@Controller +@RequestMapping("/logout") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class LogoutController { + + @NonNull LogoutService logoutService; + + /** + * The Logout Endpoint (set by urls.logout) is an application written by you. Ory Hydra redirects + * here with a logout_challenge when a logout is requested (for example via {@code + * /oauth2/sessions/logout}). The endpoint fetches the logout request from the admin API and asks + * the user to confirm, then accepts or rejects the challenge. + * + * @see Implementing The Logout Endpoint + */ + @GetMapping + public ModelAndView logoutEndpoint( + @RequestParam("logout_challenge") final String logoutChallenge) { + val response = logoutService.processInitialLogoutRequest(logoutChallenge); + + return LogoutModelAndViewMapper.map(response); + } + + @PostMapping + public ModelAndView submitLogoutForm(final LogoutForm logoutForm) { + val response = logoutService.processLogoutForm(logoutForm); + + return LogoutModelAndViewMapper.map(response); + } +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutForm.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutForm.java new file mode 100644 index 0000000..1307cd6 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutForm.java @@ -0,0 +1,13 @@ +package com.ardetrick.oryhydrareference.logout; + +/** The Java object representing the data submitted by the form in logout.ftlh. */ +public record LogoutForm(String logoutChallenge, String cancel) { + + /** + * The form has two submit buttons and only the one actually clicked is included in the form post, + * so this field is non-null exactly when the user clicked "Cancel". + */ + public boolean isCancelled() { + return cancel != null; + } +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java new file mode 100644 index 0000000..0954aa9 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java @@ -0,0 +1,27 @@ +package com.ardetrick.oryhydrareference.logout; + +import com.ardetrick.oryhydrareference.logout.LogoutResult.DisplayLogoutUI; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutAcceptedFollowRedirect; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutCancelledReturnHome; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutRequestNotFound; +import com.ardetrick.oryhydrareference.modelandview.ModelAndViewUtils; +import lombok.NonNull; +import lombok.experimental.UtilityClass; +import org.springframework.web.servlet.ModelAndView; + +@UtilityClass +public class LogoutModelAndViewMapper { + + public static ModelAndView map(@NonNull final LogoutResult result) { + return switch (result) { + case DisplayLogoutUI r -> + new ModelAndView("logout") + .addObject("logoutChallenge", r.logoutChallenge()) + .addObject("subject", r.subject()); + case LogoutAcceptedFollowRedirect r -> + ModelAndViewUtils.redirectToDifferentContext(r.redirectUrl()); + case LogoutCancelledReturnHome ignored -> new ModelAndView("/home"); + case LogoutRequestNotFound ignored -> new ModelAndView("/home"); + }; + } +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutResult.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutResult.java new file mode 100644 index 0000000..e66b195 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutResult.java @@ -0,0 +1,16 @@ +package com.ardetrick.oryhydrareference.logout; + +public sealed interface LogoutResult + permits LogoutResult.DisplayLogoutUI, + LogoutResult.LogoutAcceptedFollowRedirect, + LogoutResult.LogoutCancelledReturnHome, + LogoutResult.LogoutRequestNotFound { + + record DisplayLogoutUI(String logoutChallenge, String subject) implements LogoutResult {} + + record LogoutAcceptedFollowRedirect(String redirectUrl) implements LogoutResult {} + + record LogoutCancelledReturnHome() implements LogoutResult {} + + record LogoutRequestNotFound() implements LogoutResult {} +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutService.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutService.java new file mode 100644 index 0000000..6da73f3 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutService.java @@ -0,0 +1,40 @@ +package com.ardetrick.oryhydrareference.logout; + +import com.ardetrick.oryhydrareference.hydra.HydraAdminClient; +import com.ardetrick.oryhydrareference.logout.LogoutResult.DisplayLogoutUI; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutAcceptedFollowRedirect; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutCancelledReturnHome; +import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutRequestNotFound; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class LogoutService { + + @NonNull HydraAdminClient hydraAdminClient; + + public LogoutResult processInitialLogoutRequest(@NonNull final String logoutChallenge) { + val maybeLogoutRequest = hydraAdminClient.getLogoutRequest(logoutChallenge); + if (maybeLogoutRequest.isEmpty()) { + return new LogoutRequestNotFound(); + } + + return new DisplayLogoutUI(logoutChallenge, maybeLogoutRequest.get().getSubject()); + } + + public LogoutResult processLogoutForm(@NonNull final LogoutForm logoutForm) { + if (logoutForm.isCancelled()) { + hydraAdminClient.rejectLogoutRequest(logoutForm.logoutChallenge()); + return new LogoutCancelledReturnHome(); + } + + val redirectTo = hydraAdminClient.acceptLogoutRequest(logoutForm.logoutChallenge()); + return new LogoutAcceptedFollowRedirect(redirectTo.getRedirectTo()); + } +} diff --git a/reference-app/src/main/resources/templates/logout.ftlh b/reference-app/src/main/resources/templates/logout.ftlh new file mode 100644 index 0000000..d9460dd --- /dev/null +++ b/reference-app/src/main/resources/templates/logout.ftlh @@ -0,0 +1,16 @@ + + + + + + +

Log Out

+

Do you want to log out ${subject}?

+
+ + + + +
+ + diff --git a/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java b/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java index a8724a4..8f0caf4 100644 --- a/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java +++ b/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.net.URIBuilder; import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; @@ -100,6 +101,7 @@ void startTestEnvironment() { OryHydraContainer.builder() .urlsLogin("http://localhost:" + springBootAppPort + "/login") .urlsConsent("http://localhost:" + springBootAppPort + "/consent") + .urlsLogout("http://localhost:" + springBootAppPort + "/logout") .urlsSelfIssuer( "http://localhost:" + springBootAppPort + "/integration-test-public-proxy") .build(); @@ -510,6 +512,93 @@ public void skipLoginScreenOnSecondFlowWhenLoginRememberMeIsUsed() { // screen still appears because its remember was unchecked. assertThat(page.url()).contains("/consent"); } + + @Test + public void logoutEndsTheRememberedLoginSession() { + val screenshotPathProducer = + ScreenshotPathProducer.builder().testName("logoutEndsTheRememberedLoginSession").build(); + + val page = browser.newPage(); + + // First flow: log in with remember-me so a durable login session exists. + completeFullFlowWithLoginRemember(page, screenshotPathProducer); + + // Second flow: no credentials needed — proof the session is live before logging out. + page.navigate(getUriToInitiateFlow().toString()); + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("second-flow-skips-login")); + assertThat(page.url()).doesNotContain("/login"); + + // RP-initiated logout: Hydra redirects to this app's logout endpoint with a challenge. + page.navigate(oryHydraContainer.publicBaseUriString() + "/oauth2/sessions/logout"); + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("logout-confirmation")); + assertThat(page.url()).contains("/logout?logout_challenge="); + + page.locator("input[id=confirm]").click(); + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-logout-confirm")); + assertThat(page.url()).contains("/oauth2/fallbacks/logout"); + + // Third flow: the session is gone, so the login screen is back. + page.navigate(getUriToInitiateFlow().toString()); + page.waitForLoadState(); + page.screenshot( + screenshotPathProducer.screenshotOptionsForStepName("flow-after-logout-requires-login")); + assertThat(page.url()).contains("/login"); + } + + @Test + public void cancellingLogoutKeepsTheSessionAlive() { + val screenshotPathProducer = + ScreenshotPathProducer.builder().testName("cancellingLogoutKeepsTheSessionAlive").build(); + + val page = browser.newPage(); + + completeFullFlowWithLoginRemember(page, screenshotPathProducer); + + page.navigate(oryHydraContainer.publicBaseUriString() + "/oauth2/sessions/logout"); + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("logout-confirmation")); + + page.locator("input[id=cancel]").click(); + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-logout-cancel")); + + // The session survives a cancelled logout: the next flow still skips the login screen. + page.navigate(getUriToInitiateFlow().toString()); + page.waitForLoadState(); + page.screenshot( + screenshotPathProducer.screenshotOptionsForStepName("flow-after-cancel-skips-login")); + assertThat(page.url()).doesNotContain("/login"); + } + + /** + * Runs one full authorization-code flow with login remember-me checked, ending at the client + * callback with the code exchanged. + */ + private void completeFullFlowWithLoginRemember( + Page page, ScreenshotPathProducer screenshotPathProducer) { + page.navigate(getUriToInitiateFlow().toString()); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("initial-load")); + + page.locator("input[name=loginEmail]").fill("foo@bar.com"); + page.locator("input[name=loginPassword]").fill("password"); + page.locator("input[id=remember]").check(); + + page.locator("input[name=submit]").click(); + + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-login-submit")); + + page.locator("input[id=accept]").click(); + + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-consent-submit")); + + val code = getCodeFromCallbackCaptor(); + exchangeCode(code); + } } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @@ -545,6 +634,23 @@ public RedirectView oauth2Auth() { return redirectView; } + // The logout accept's redirect_to (carrying the logout_verifier) and the post-logout + // fallback are both issuer-based URLs, so they arrive here and forward to Hydra like + // the authorize endpoint above. + @GetMapping("oauth2/sessions/logout") + public RedirectView oauth2SessionsLogout() { + val redirectView = new RedirectView(hydraPublicBaseUri + "/oauth2/sessions/logout"); + redirectView.setPropagateQueryParams(true); + return redirectView; + } + + @GetMapping("oauth2/fallbacks/logout") + public RedirectView oauth2FallbacksLogout() { + val redirectView = new RedirectView(hydraPublicBaseUri + "/oauth2/fallbacks/logout"); + redirectView.setPropagateQueryParams(true); + return redirectView; + } + @GetMapping("oauth2/fallbacks/error") public String oauth2FallbacksError(HttpServletRequest request) { return null;