Skip to content

Commit b9c7aff

Browse files
authored
feat: implement the Hydra logout flow (#186)
1 parent 8977293 commit b9c7aff

10 files changed

Lines changed: 308 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,6 @@ instead, use a Gradle composite build:
359359
- [ ] Document playwright usage
360360
- [ ] Show login errors on login screen
361361
- [ ] Add playwright traces https://playwright.dev/java/docs/trace-viewer-intro
362-
- [ ] Log out
362+
- [x] Log out
363363
- [ ] Add example with Ory Cloud
364364

reference-app/src/main/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplication.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ public static void main(String[] args) {
1818
SecurityFilterChain securityFilterChain(HttpSecurity http) {
1919
// Don't do this in production. This was removed to simplify this reference implementation.
2020
http.csrf(AbstractHttpConfigurer::disable)
21-
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
21+
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
22+
// Spring Security's built-in LogoutFilter intercepts /logout before it can reach any
23+
// controller (with CSRF disabled, even on GET) and redirects to its own /login?logout.
24+
// This app's /logout is Hydra's logout challenge endpoint, not an app-session logout,
25+
// so the filter must be disabled for LogoutController to receive the request.
26+
.logout(AbstractHttpConfigurer::disable);
2227
return http.build();
2328
}
2429
}

reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ public OAuth2RedirectTo rejectConsentRequest(@NonNull RejectConsentRequest rejec
113113
}
114114
}
115115

116+
public Optional<OAuth2LogoutRequest> getLogoutRequest(@NonNull String logoutChallenge) {
117+
try {
118+
return Optional.of(oAuth2Api().getOAuth2LogoutRequest(logoutChallenge));
119+
} catch (ApiException e) {
120+
return switch (e.getCode()) {
121+
case 410 -> Optional.empty(); // requestWasHandledResponse
122+
case 404 -> Optional.empty(); // jsonError
123+
default -> throw new RuntimeException("unhandled code: " + e.getCode(), e);
124+
};
125+
}
126+
}
127+
128+
public OAuth2RedirectTo acceptLogoutRequest(@NonNull String logoutChallenge) {
129+
try {
130+
return oAuth2Api().acceptOAuth2LogoutRequest(logoutChallenge);
131+
} catch (ApiException e) {
132+
switch (e.getCode()) {
133+
case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError
134+
default -> throw new RuntimeException("unhandled code: " + e.getCode(), e);
135+
}
136+
}
137+
}
138+
139+
/**
140+
* Unlike a rejected login or consent, a rejected logout has no redirect: Hydra returns 204 and
141+
* the session stays alive. Where to send the user afterwards is this app's choice.
142+
*/
143+
public void rejectLogoutRequest(@NonNull String logoutChallenge) {
144+
try {
145+
oAuth2Api().rejectOAuth2LogoutRequest(logoutChallenge);
146+
} catch (ApiException e) {
147+
switch (e.getCode()) {
148+
case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError
149+
default -> throw new RuntimeException("unhandled code: " + e.getCode(), e);
150+
}
151+
}
152+
}
153+
116154
@Data
117155
@org.springframework.context.annotation.Configuration
118156
@ConfigurationProperties("reference-app.hydra")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.ardetrick.oryhydrareference.logout;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NonNull;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.experimental.FieldDefaults;
7+
import lombok.val;
8+
import org.springframework.stereotype.Controller;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
import org.springframework.web.servlet.ModelAndView;
14+
15+
@Controller
16+
@RequestMapping("/logout")
17+
@RequiredArgsConstructor
18+
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
19+
public class LogoutController {
20+
21+
@NonNull LogoutService logoutService;
22+
23+
/**
24+
* The Logout Endpoint (set by urls.logout) is an application written by you. Ory Hydra redirects
25+
* here with a logout_challenge when a logout is requested (for example via {@code
26+
* /oauth2/sessions/logout}). The endpoint fetches the logout request from the admin API and asks
27+
* the user to confirm, then accepts or rejects the challenge.
28+
*
29+
* @see <a href="https://www.ory.sh/docs/hydra/guides/logout">Implementing The Logout Endpoint</a>
30+
*/
31+
@GetMapping
32+
public ModelAndView logoutEndpoint(
33+
@RequestParam("logout_challenge") final String logoutChallenge) {
34+
val response = logoutService.processInitialLogoutRequest(logoutChallenge);
35+
36+
return LogoutModelAndViewMapper.map(response);
37+
}
38+
39+
@PostMapping
40+
public ModelAndView submitLogoutForm(final LogoutForm logoutForm) {
41+
val response = logoutService.processLogoutForm(logoutForm);
42+
43+
return LogoutModelAndViewMapper.map(response);
44+
}
45+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.ardetrick.oryhydrareference.logout;
2+
3+
/** The Java object representing the data submitted by the form in logout.ftlh. */
4+
public record LogoutForm(String logoutChallenge, String cancel) {
5+
6+
/**
7+
* The form has two submit buttons and only the one actually clicked is included in the form post,
8+
* so this field is non-null exactly when the user clicked "Cancel".
9+
*/
10+
public boolean isCancelled() {
11+
return cancel != null;
12+
}
13+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.ardetrick.oryhydrareference.logout;
2+
3+
import com.ardetrick.oryhydrareference.logout.LogoutResult.DisplayLogoutUI;
4+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutAcceptedFollowRedirect;
5+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutCancelledReturnHome;
6+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutRequestNotFound;
7+
import com.ardetrick.oryhydrareference.modelandview.ModelAndViewUtils;
8+
import lombok.NonNull;
9+
import lombok.experimental.UtilityClass;
10+
import org.springframework.web.servlet.ModelAndView;
11+
12+
@UtilityClass
13+
public class LogoutModelAndViewMapper {
14+
15+
public static ModelAndView map(@NonNull final LogoutResult result) {
16+
return switch (result) {
17+
case DisplayLogoutUI r ->
18+
new ModelAndView("logout")
19+
.addObject("logoutChallenge", r.logoutChallenge())
20+
.addObject("subject", r.subject());
21+
case LogoutAcceptedFollowRedirect r ->
22+
ModelAndViewUtils.redirectToDifferentContext(r.redirectUrl());
23+
case LogoutCancelledReturnHome ignored -> new ModelAndView("/home");
24+
case LogoutRequestNotFound ignored -> new ModelAndView("/home");
25+
};
26+
}
27+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.ardetrick.oryhydrareference.logout;
2+
3+
public sealed interface LogoutResult
4+
permits LogoutResult.DisplayLogoutUI,
5+
LogoutResult.LogoutAcceptedFollowRedirect,
6+
LogoutResult.LogoutCancelledReturnHome,
7+
LogoutResult.LogoutRequestNotFound {
8+
9+
record DisplayLogoutUI(String logoutChallenge, String subject) implements LogoutResult {}
10+
11+
record LogoutAcceptedFollowRedirect(String redirectUrl) implements LogoutResult {}
12+
13+
record LogoutCancelledReturnHome() implements LogoutResult {}
14+
15+
record LogoutRequestNotFound() implements LogoutResult {}
16+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.ardetrick.oryhydrareference.logout;
2+
3+
import com.ardetrick.oryhydrareference.hydra.HydraAdminClient;
4+
import com.ardetrick.oryhydrareference.logout.LogoutResult.DisplayLogoutUI;
5+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutAcceptedFollowRedirect;
6+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutCancelledReturnHome;
7+
import com.ardetrick.oryhydrareference.logout.LogoutResult.LogoutRequestNotFound;
8+
import lombok.AccessLevel;
9+
import lombok.NonNull;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.experimental.FieldDefaults;
12+
import lombok.val;
13+
import org.springframework.stereotype.Service;
14+
15+
@Service
16+
@RequiredArgsConstructor
17+
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
18+
public class LogoutService {
19+
20+
@NonNull HydraAdminClient hydraAdminClient;
21+
22+
public LogoutResult processInitialLogoutRequest(@NonNull final String logoutChallenge) {
23+
val maybeLogoutRequest = hydraAdminClient.getLogoutRequest(logoutChallenge);
24+
if (maybeLogoutRequest.isEmpty()) {
25+
return new LogoutRequestNotFound();
26+
}
27+
28+
return new DisplayLogoutUI(logoutChallenge, maybeLogoutRequest.get().getSubject());
29+
}
30+
31+
public LogoutResult processLogoutForm(@NonNull final LogoutForm logoutForm) {
32+
if (logoutForm.isCancelled()) {
33+
hydraAdminClient.rejectLogoutRequest(logoutForm.logoutChallenge());
34+
return new LogoutCancelledReturnHome();
35+
}
36+
37+
val redirectTo = hydraAdminClient.acceptLogoutRequest(logoutForm.logoutChallenge());
38+
return new LogoutAcceptedFollowRedirect(redirectTo.getRedirectTo());
39+
}
40+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
</head>
6+
<body>
7+
<h2>Log Out</h2>
8+
<p>Do you want to log out ${subject}?</p>
9+
<form action="/logout" method="post">
10+
<input type="hidden" name="logoutChallenge" value="${logoutChallenge}" />
11+
12+
<input type="submit" id="confirm" name="confirm" value="Yes, log out" />
13+
<input type="submit" id="cancel" name="cancel" value="Cancel" />
14+
</form>
15+
</body>
16+
</html>

reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.fasterxml.jackson.databind.annotation.JsonNaming;
1616
import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.net.URIBuilder;
1717
import com.microsoft.playwright.Browser;
18+
import com.microsoft.playwright.Page;
1819
import com.microsoft.playwright.Playwright;
1920
import jakarta.servlet.http.HttpServletRequest;
2021
import java.io.IOException;
@@ -100,6 +101,7 @@ void startTestEnvironment() {
100101
OryHydraContainer.builder()
101102
.urlsLogin("http://localhost:" + springBootAppPort + "/login")
102103
.urlsConsent("http://localhost:" + springBootAppPort + "/consent")
104+
.urlsLogout("http://localhost:" + springBootAppPort + "/logout")
103105
.urlsSelfIssuer(
104106
"http://localhost:" + springBootAppPort + "/integration-test-public-proxy")
105107
.build();
@@ -510,6 +512,93 @@ public void skipLoginScreenOnSecondFlowWhenLoginRememberMeIsUsed() {
510512
// screen still appears because its remember was unchecked.
511513
assertThat(page.url()).contains("/consent");
512514
}
515+
516+
@Test
517+
public void logoutEndsTheRememberedLoginSession() {
518+
val screenshotPathProducer =
519+
ScreenshotPathProducer.builder().testName("logoutEndsTheRememberedLoginSession").build();
520+
521+
val page = browser.newPage();
522+
523+
// First flow: log in with remember-me so a durable login session exists.
524+
completeFullFlowWithLoginRemember(page, screenshotPathProducer);
525+
526+
// Second flow: no credentials needed — proof the session is live before logging out.
527+
page.navigate(getUriToInitiateFlow().toString());
528+
page.waitForLoadState();
529+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("second-flow-skips-login"));
530+
assertThat(page.url()).doesNotContain("/login");
531+
532+
// RP-initiated logout: Hydra redirects to this app's logout endpoint with a challenge.
533+
page.navigate(oryHydraContainer.publicBaseUriString() + "/oauth2/sessions/logout");
534+
page.waitForLoadState();
535+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("logout-confirmation"));
536+
assertThat(page.url()).contains("/logout?logout_challenge=");
537+
538+
page.locator("input[id=confirm]").click();
539+
page.waitForLoadState();
540+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-logout-confirm"));
541+
assertThat(page.url()).contains("/oauth2/fallbacks/logout");
542+
543+
// Third flow: the session is gone, so the login screen is back.
544+
page.navigate(getUriToInitiateFlow().toString());
545+
page.waitForLoadState();
546+
page.screenshot(
547+
screenshotPathProducer.screenshotOptionsForStepName("flow-after-logout-requires-login"));
548+
assertThat(page.url()).contains("/login");
549+
}
550+
551+
@Test
552+
public void cancellingLogoutKeepsTheSessionAlive() {
553+
val screenshotPathProducer =
554+
ScreenshotPathProducer.builder().testName("cancellingLogoutKeepsTheSessionAlive").build();
555+
556+
val page = browser.newPage();
557+
558+
completeFullFlowWithLoginRemember(page, screenshotPathProducer);
559+
560+
page.navigate(oryHydraContainer.publicBaseUriString() + "/oauth2/sessions/logout");
561+
page.waitForLoadState();
562+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("logout-confirmation"));
563+
564+
page.locator("input[id=cancel]").click();
565+
page.waitForLoadState();
566+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-logout-cancel"));
567+
568+
// The session survives a cancelled logout: the next flow still skips the login screen.
569+
page.navigate(getUriToInitiateFlow().toString());
570+
page.waitForLoadState();
571+
page.screenshot(
572+
screenshotPathProducer.screenshotOptionsForStepName("flow-after-cancel-skips-login"));
573+
assertThat(page.url()).doesNotContain("/login");
574+
}
575+
576+
/**
577+
* Runs one full authorization-code flow with login remember-me checked, ending at the client
578+
* callback with the code exchanged.
579+
*/
580+
private void completeFullFlowWithLoginRemember(
581+
Page page, ScreenshotPathProducer screenshotPathProducer) {
582+
page.navigate(getUriToInitiateFlow().toString());
583+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("initial-load"));
584+
585+
page.locator("input[name=loginEmail]").fill("foo@bar.com");
586+
page.locator("input[name=loginPassword]").fill("password");
587+
page.locator("input[id=remember]").check();
588+
589+
page.locator("input[name=submit]").click();
590+
591+
page.waitForLoadState();
592+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-login-submit"));
593+
594+
page.locator("input[id=accept]").click();
595+
596+
page.waitForLoadState();
597+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-consent-submit"));
598+
599+
val code = getCodeFromCallbackCaptor();
600+
exchangeCode(code);
601+
}
513602
}
514603

515604
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@@ -545,6 +634,23 @@ public RedirectView oauth2Auth() {
545634
return redirectView;
546635
}
547636

637+
// The logout accept's redirect_to (carrying the logout_verifier) and the post-logout
638+
// fallback are both issuer-based URLs, so they arrive here and forward to Hydra like
639+
// the authorize endpoint above.
640+
@GetMapping("oauth2/sessions/logout")
641+
public RedirectView oauth2SessionsLogout() {
642+
val redirectView = new RedirectView(hydraPublicBaseUri + "/oauth2/sessions/logout");
643+
redirectView.setPropagateQueryParams(true);
644+
return redirectView;
645+
}
646+
647+
@GetMapping("oauth2/fallbacks/logout")
648+
public RedirectView oauth2FallbacksLogout() {
649+
val redirectView = new RedirectView(hydraPublicBaseUri + "/oauth2/fallbacks/logout");
650+
redirectView.setPropagateQueryParams(true);
651+
return redirectView;
652+
}
653+
548654
@GetMapping("oauth2/fallbacks/error")
549655
public String oauth2FallbacksError(HttpServletRequest request) {
550656
return null;

0 commit comments

Comments
 (0)