Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,44 @@ public OAuth2RedirectTo rejectConsentRequest(@NonNull RejectConsentRequest rejec
}
}

public Optional<OAuth2LogoutRequest> 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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <a href="https://www.ory.sh/docs/hydra/guides/logout">Implementing The Logout Endpoint</a>
*/
@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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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");
};
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
16 changes: 16 additions & 0 deletions reference-app/src/main/resources/templates/logout.ftlh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<h2>Log Out</h2>
<p>Do you want to log out ${subject}?</p>
<form action="/logout" method="post">
<input type="hidden" name="logoutChallenge" value="${logoutChallenge}" />

<input type="submit" id="confirm" name="confirm" value="Yes, log out" />
<input type="submit" id="cancel" name="cancel" value="Cancel" />
</form>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down