|
15 | 15 | import com.fasterxml.jackson.databind.annotation.JsonNaming; |
16 | 16 | import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.net.URIBuilder; |
17 | 17 | import com.microsoft.playwright.Browser; |
| 18 | +import com.microsoft.playwright.Page; |
18 | 19 | import com.microsoft.playwright.Playwright; |
19 | 20 | import jakarta.servlet.http.HttpServletRequest; |
20 | 21 | import java.io.IOException; |
@@ -100,6 +101,7 @@ void startTestEnvironment() { |
100 | 101 | OryHydraContainer.builder() |
101 | 102 | .urlsLogin("http://localhost:" + springBootAppPort + "/login") |
102 | 103 | .urlsConsent("http://localhost:" + springBootAppPort + "/consent") |
| 104 | + .urlsLogout("http://localhost:" + springBootAppPort + "/logout") |
103 | 105 | .urlsSelfIssuer( |
104 | 106 | "http://localhost:" + springBootAppPort + "/integration-test-public-proxy") |
105 | 107 | .build(); |
@@ -510,6 +512,93 @@ public void skipLoginScreenOnSecondFlowWhenLoginRememberMeIsUsed() { |
510 | 512 | // screen still appears because its remember was unchecked. |
511 | 513 | assertThat(page.url()).contains("/consent"); |
512 | 514 | } |
| 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 | + } |
513 | 602 | } |
514 | 603 |
|
515 | 604 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) |
@@ -545,6 +634,23 @@ public RedirectView oauth2Auth() { |
545 | 634 | return redirectView; |
546 | 635 | } |
547 | 636 |
|
| 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 | + |
548 | 654 | @GetMapping("oauth2/fallbacks/error") |
549 | 655 | public String oauth2FallbacksError(HttpServletRequest request) { |
550 | 656 | return null; |
|
0 commit comments