From a1f1f9cff32c54cacc56d98f97655d366028b20a Mon Sep 17 00:00:00 2001 From: ardetrick <1452182+ardetrick@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:51:11 -0500 Subject: [PATCH] feat: reject the consent request when the user denies access --- README.md | 2 +- .../consent/ConsentForm.java | 10 ++++- .../consent/ConsentModelAndViewMapper.java | 7 ++++ .../consent/ConsentResponse.java | 7 +++- .../consent/ConsentService.java | 13 ++++++ .../hydra/HydraAdminClient.java | 14 +++++++ .../hydra/OryHydraRequestMapper.java | 7 ++++ .../hydra/RejectConsentRequest.java | 8 ++++ .../src/main/resources/templates/consent.ftlh | 4 +- ...raReferenceApplicationFunctionalTests.java | 42 +++++++++++++++++-- 10 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/RejectConsentRequest.java diff --git a/README.md b/README.md index 99309c0..3255f63 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ instead, use a Gradle composite build: - [ ] Add a fancier UI - [ ] Refactor controller/service logic to be more consistent - [ ] Tests to mock Ory Hydra responses -- [ ] Allow rejecting on consent screen +- [x] Allow rejecting on consent screen - [ ] Add more unit tests - [ ] Document playwright usage - [ ] Show login errors on login screen diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentForm.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentForm.java index 5f052ed..3d792b2 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentForm.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentForm.java @@ -4,7 +4,7 @@ /** The Java object representing the data submitted by the form in consent.ftlh. */ public record ConsentForm( - String submit, String consentChallenge, Boolean remember, List scopes) { + String consentChallenge, Boolean remember, List scopes, String deny) { /** * This field is implemented in HTML as a checkbox. When a checkbox element is not checked in a @@ -16,4 +16,12 @@ public boolean isRemember() { } return remember; } + + /** + * 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 "Deny access". + */ + public boolean isDenied() { + return deny != null; + } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentModelAndViewMapper.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentModelAndViewMapper.java index 5b033f0..5e18d21 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentModelAndViewMapper.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentModelAndViewMapper.java @@ -1,6 +1,7 @@ package com.ardetrick.oryhydrareference.consent; import com.ardetrick.oryhydrareference.consent.ConsentResponse.Accepted; +import com.ardetrick.oryhydrareference.consent.ConsentResponse.Denied; import com.ardetrick.oryhydrareference.consent.ConsentResponse.DisplayUI; import com.ardetrick.oryhydrareference.consent.ConsentResponse.Skip; import com.ardetrick.oryhydrareference.modelandview.ModelAndViewUtils; @@ -14,6 +15,7 @@ public static ModelAndView map(@NonNull final ConsentResponse response) { case Skip r -> handleSkip(r); case DisplayUI r -> handleDisplayUI(r); case Accepted r -> handleAccepted(r); + case Denied r -> handleDenied(r); }; } @@ -30,4 +32,9 @@ private static ModelAndView handleDisplayUI(DisplayUI displayUI) { private static ModelAndView handleAccepted(Accepted accepted) { return ModelAndViewUtils.redirectToDifferentContext(accepted.redirectTo()); } + + // Hydra's redirect carries the OAuth error (error=access_denied&...) back to the client. + private static ModelAndView handleDenied(Denied denied) { + return ModelAndViewUtils.redirectToDifferentContext(denied.redirectTo()); + } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentResponse.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentResponse.java index 0ef7e1b..61fd4f6 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentResponse.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentResponse.java @@ -3,7 +3,10 @@ import java.util.List; public sealed interface ConsentResponse - permits ConsentResponse.Accepted, ConsentResponse.DisplayUI, ConsentResponse.Skip { + permits ConsentResponse.Accepted, + ConsentResponse.Denied, + ConsentResponse.DisplayUI, + ConsentResponse.Skip { record Skip(String redirectTo) implements ConsentResponse {} @@ -11,4 +14,6 @@ record DisplayUI(List requestedScopes, String consentChallenge) implements ConsentResponse {} record Accepted(String redirectTo) implements ConsentResponse {} + + record Denied(String redirectTo) implements ConsentResponse {} } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentService.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentService.java index 2708b45..a9eded6 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentService.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentService.java @@ -1,10 +1,12 @@ package com.ardetrick.oryhydrareference.consent; import com.ardetrick.oryhydrareference.consent.ConsentResponse.Accepted; +import com.ardetrick.oryhydrareference.consent.ConsentResponse.Denied; import com.ardetrick.oryhydrareference.consent.ConsentResponse.DisplayUI; import com.ardetrick.oryhydrareference.consent.ConsentResponse.Skip; import com.ardetrick.oryhydrareference.hydra.AcceptConsentRequest; import com.ardetrick.oryhydrareference.hydra.HydraAdminClient; +import com.ardetrick.oryhydrareference.hydra.RejectConsentRequest; import lombok.AccessLevel; import lombok.NonNull; import lombok.RequiredArgsConstructor; @@ -40,6 +42,17 @@ public ConsentResponse processInitialConsentRequest(@NonNull final String consen public ConsentResponse processConsentForm(@NonNull final ConsentForm consentForm) { val consentChallenge = consentForm.consentChallenge(); + if (consentForm.isDenied()) { + val rejectConsentRequest = + RejectConsentRequest.builder() + .consentChallenge(consentChallenge) + .error("access_denied") + .errorDescription("user denied access") + .build(); + val rejectConsentResponse = hydraAdminClient.rejectConsentRequest(rejectConsentRequest); + return new Denied(rejectConsentResponse.getRedirectTo()); + } + val consentRequest = hydraAdminClient.getConsentRequest(consentChallenge); val acceptConsentRequest = 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 5f74d18..73a61fb 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 @@ -101,6 +101,20 @@ public OAuth2RedirectTo acceptConsentRequest(@NonNull AcceptConsentRequest accep } } + public OAuth2RedirectTo rejectConsentRequest(@NonNull RejectConsentRequest rejectConsentRequest) { + val rejectOAuth2Request = OryHydraRequestMapper.map(rejectConsentRequest); + + try { + return oAuth2Api() + .rejectOAuth2ConsentRequest(rejectConsentRequest.consentChallenge(), rejectOAuth2Request); + } 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/hydra/OryHydraRequestMapper.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/OryHydraRequestMapper.java index d63baf5..8037a77 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/OryHydraRequestMapper.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/OryHydraRequestMapper.java @@ -3,6 +3,7 @@ import lombok.NonNull; import sh.ory.hydra.model.AcceptOAuth2ConsentRequest; import sh.ory.hydra.model.AcceptOAuth2ConsentRequestSession; +import sh.ory.hydra.model.RejectOAuth2Request; public class OryHydraRequestMapper { @@ -18,6 +19,12 @@ public static AcceptOAuth2ConsentRequest map( .session(getAcceptOAuth2ConsentRequestSession()); } + public static RejectOAuth2Request map(@NonNull final RejectConsentRequest rejectConsentRequest) { + return new RejectOAuth2Request() + .error(rejectConsentRequest.error()) + .errorDescription(rejectConsentRequest.errorDescription()); + } + private static AcceptOAuth2ConsentRequestSession getAcceptOAuth2ConsentRequestSession() { // An example of setting custom claims. record ExampleCustomClaims(String exampleCustomClaimKey) {} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/RejectConsentRequest.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/RejectConsentRequest.java new file mode 100644 index 0000000..e9b22b2 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/RejectConsentRequest.java @@ -0,0 +1,8 @@ +package com.ardetrick.oryhydrareference.hydra; + +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record RejectConsentRequest( + @NonNull String consentChallenge, @NonNull String error, String errorDescription) {} diff --git a/reference-app/src/main/resources/templates/consent.ftlh b/reference-app/src/main/resources/templates/consent.ftlh index 390c166..f54e4c7 100644 --- a/reference-app/src/main/resources/templates/consent.ftlh +++ b/reference-app/src/main/resources/templates/consent.ftlh @@ -18,8 +18,8 @@
- - + + 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 9f39097..a5f922f 100644 --- a/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java +++ b/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java @@ -367,10 +367,13 @@ public void skipConsentScreenOnSecondLoginWhenRememberMeIsUsed() { assertThat(page.url()).contains("/client/callback?code="); } - private String getCodeFromCallbackCaptor() { + private String getCallbackQueryString() { verify(queryStringConsumer).accept(queryStringConsumerArgumentCaptor.capture()); - val queryStringValue = queryStringConsumerArgumentCaptor.getValue(); - return Arrays.stream(queryStringValue.split("&")) + return queryStringConsumerArgumentCaptor.getValue(); + } + + private String getCodeFromCallbackCaptor() { + return Arrays.stream(getCallbackQueryString().split("&")) .filter(queryStringParam -> queryStringParam.startsWith("code=")) .findFirst() .map(queryStringParam -> queryStringParam.replace("code=", "")) @@ -429,6 +432,39 @@ public void doNotSkipConsentScreenOnSecondLoginWhenRememberMeIsFalse() { // Consent screen is not skipped assertThat(page.url()).contains("/consent"); } + + @Test + public void denyConsentRedirectsToClientWithAccessDeniedError() { + val screenshotPathProducer = + ScreenshotPathProducer.builder() + .testName("denyConsentRedirectsToClientWithAccessDeniedError") + .build(); + + val page = browser.newPage(); + + 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[name=submit]").click(); + + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-login-submit")); + + page.locator("input[id=reject]").click(); + + page.waitForLoadState(); + page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-consent-deny")); + + // The browser lands on the client's callback carrying a real OAuth error instead of a code. + assertThat(page.url()).contains("/client/callback?error=access_denied"); + + val queryString = getCallbackQueryString(); + assertThat(queryString).contains("error=access_denied"); + assertThat(queryString).doesNotContain("code="); + } } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)