Skip to content

Commit b5778f1

Browse files
authored
feat: reject the consent request when the user denies access (#183)
1 parent 93f3127 commit b5778f1

10 files changed

Lines changed: 106 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ instead, use a Gradle composite build:
357357
- [ ] Add a fancier UI
358358
- [ ] Refactor controller/service logic to be more consistent
359359
- [ ] Tests to mock Ory Hydra responses
360-
- [ ] Allow rejecting on consent screen
360+
- [x] Allow rejecting on consent screen
361361
- [ ] Add more unit tests
362362
- [ ] Document playwright usage
363363
- [ ] Show login errors on login screen

reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentForm.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
/** The Java object representing the data submitted by the form in consent.ftlh. */
66
public record ConsentForm(
7-
String submit, String consentChallenge, Boolean remember, List<String> scopes) {
7+
String consentChallenge, Boolean remember, List<String> scopes, String deny) {
88

99
/**
1010
* 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() {
1616
}
1717
return remember;
1818
}
19+
20+
/**
21+
* The form has two submit buttons and only the one actually clicked is included in the form post,
22+
* so this field is non-null exactly when the user clicked "Deny access".
23+
*/
24+
public boolean isDenied() {
25+
return deny != null;
26+
}
1927
}

reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentModelAndViewMapper.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.ardetrick.oryhydrareference.consent;
22

33
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Accepted;
4+
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Denied;
45
import com.ardetrick.oryhydrareference.consent.ConsentResponse.DisplayUI;
56
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Skip;
67
import com.ardetrick.oryhydrareference.modelandview.ModelAndViewUtils;
@@ -14,6 +15,7 @@ public static ModelAndView map(@NonNull final ConsentResponse response) {
1415
case Skip r -> handleSkip(r);
1516
case DisplayUI r -> handleDisplayUI(r);
1617
case Accepted r -> handleAccepted(r);
18+
case Denied r -> handleDenied(r);
1719
};
1820
}
1921

@@ -30,4 +32,9 @@ private static ModelAndView handleDisplayUI(DisplayUI displayUI) {
3032
private static ModelAndView handleAccepted(Accepted accepted) {
3133
return ModelAndViewUtils.redirectToDifferentContext(accepted.redirectTo());
3234
}
35+
36+
// Hydra's redirect carries the OAuth error (error=access_denied&...) back to the client.
37+
private static ModelAndView handleDenied(Denied denied) {
38+
return ModelAndViewUtils.redirectToDifferentContext(denied.redirectTo());
39+
}
3340
}

reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
import java.util.List;
44

55
public sealed interface ConsentResponse
6-
permits ConsentResponse.Accepted, ConsentResponse.DisplayUI, ConsentResponse.Skip {
6+
permits ConsentResponse.Accepted,
7+
ConsentResponse.Denied,
8+
ConsentResponse.DisplayUI,
9+
ConsentResponse.Skip {
710

811
record Skip(String redirectTo) implements ConsentResponse {}
912

1013
record DisplayUI(List<String> requestedScopes, String consentChallenge)
1114
implements ConsentResponse {}
1215

1316
record Accepted(String redirectTo) implements ConsentResponse {}
17+
18+
record Denied(String redirectTo) implements ConsentResponse {}
1419
}

reference-app/src/main/java/com/ardetrick/oryhydrareference/consent/ConsentService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.ardetrick.oryhydrareference.consent;
22

33
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Accepted;
4+
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Denied;
45
import com.ardetrick.oryhydrareference.consent.ConsentResponse.DisplayUI;
56
import com.ardetrick.oryhydrareference.consent.ConsentResponse.Skip;
67
import com.ardetrick.oryhydrareference.hydra.AcceptConsentRequest;
78
import com.ardetrick.oryhydrareference.hydra.HydraAdminClient;
9+
import com.ardetrick.oryhydrareference.hydra.RejectConsentRequest;
810
import lombok.AccessLevel;
911
import lombok.NonNull;
1012
import lombok.RequiredArgsConstructor;
@@ -40,6 +42,17 @@ public ConsentResponse processInitialConsentRequest(@NonNull final String consen
4042
public ConsentResponse processConsentForm(@NonNull final ConsentForm consentForm) {
4143
val consentChallenge = consentForm.consentChallenge();
4244

45+
if (consentForm.isDenied()) {
46+
val rejectConsentRequest =
47+
RejectConsentRequest.builder()
48+
.consentChallenge(consentChallenge)
49+
.error("access_denied")
50+
.errorDescription("user denied access")
51+
.build();
52+
val rejectConsentResponse = hydraAdminClient.rejectConsentRequest(rejectConsentRequest);
53+
return new Denied(rejectConsentResponse.getRedirectTo());
54+
}
55+
4356
val consentRequest = hydraAdminClient.getConsentRequest(consentChallenge);
4457

4558
val acceptConsentRequest =

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ public OAuth2RedirectTo acceptConsentRequest(@NonNull AcceptConsentRequest accep
101101
}
102102
}
103103

104+
public OAuth2RedirectTo rejectConsentRequest(@NonNull RejectConsentRequest rejectConsentRequest) {
105+
val rejectOAuth2Request = OryHydraRequestMapper.map(rejectConsentRequest);
106+
107+
try {
108+
return oAuth2Api()
109+
.rejectOAuth2ConsentRequest(rejectConsentRequest.consentChallenge(), rejectOAuth2Request);
110+
} catch (ApiException e) {
111+
switch (e.getCode()) {
112+
case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError
113+
default -> throw new RuntimeException("unhandled code: " + e.getCode(), e);
114+
}
115+
}
116+
}
117+
104118
@Data
105119
@org.springframework.context.annotation.Configuration
106120
@ConfigurationProperties("reference-app.hydra")

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import lombok.NonNull;
44
import sh.ory.hydra.model.AcceptOAuth2ConsentRequest;
55
import sh.ory.hydra.model.AcceptOAuth2ConsentRequestSession;
6+
import sh.ory.hydra.model.RejectOAuth2Request;
67

78
public class OryHydraRequestMapper {
89

@@ -18,6 +19,12 @@ public static AcceptOAuth2ConsentRequest map(
1819
.session(getAcceptOAuth2ConsentRequestSession());
1920
}
2021

22+
public static RejectOAuth2Request map(@NonNull final RejectConsentRequest rejectConsentRequest) {
23+
return new RejectOAuth2Request()
24+
.error(rejectConsentRequest.error())
25+
.errorDescription(rejectConsentRequest.errorDescription());
26+
}
27+
2128
private static AcceptOAuth2ConsentRequestSession getAcceptOAuth2ConsentRequestSession() {
2229
// An example of setting custom claims.
2330
record ExampleCustomClaims(String exampleCustomClaimKey) {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.ardetrick.oryhydrareference.hydra;
2+
3+
import lombok.Builder;
4+
import lombok.NonNull;
5+
6+
@Builder
7+
public record RejectConsentRequest(
8+
@NonNull String consentChallenge, @NonNull String error, String errorDescription) {}

reference-app/src/main/resources/templates/consent.ftlh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
<label for="remember">Remember me:</label>
1919
<input type="checkbox" id="remember" name="remember" checked /><br>
2020

21-
<input type="submit" id="accept" name="submit" value="Allow access" />
22-
<input type="submit" id="reject" name="submit" value="Deny access" />
21+
<input type="submit" id="accept" name="accept" value="Allow access" />
22+
<input type="submit" id="reject" name="deny" value="Deny access" />
2323
</form>
2424
</body>
2525
</html>

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

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,10 +367,13 @@ public void skipConsentScreenOnSecondLoginWhenRememberMeIsUsed() {
367367
assertThat(page.url()).contains("/client/callback?code=");
368368
}
369369

370-
private String getCodeFromCallbackCaptor() {
370+
private String getCallbackQueryString() {
371371
verify(queryStringConsumer).accept(queryStringConsumerArgumentCaptor.capture());
372-
val queryStringValue = queryStringConsumerArgumentCaptor.getValue();
373-
return Arrays.stream(queryStringValue.split("&"))
372+
return queryStringConsumerArgumentCaptor.getValue();
373+
}
374+
375+
private String getCodeFromCallbackCaptor() {
376+
return Arrays.stream(getCallbackQueryString().split("&"))
374377
.filter(queryStringParam -> queryStringParam.startsWith("code="))
375378
.findFirst()
376379
.map(queryStringParam -> queryStringParam.replace("code=", ""))
@@ -429,6 +432,39 @@ public void doNotSkipConsentScreenOnSecondLoginWhenRememberMeIsFalse() {
429432
// Consent screen is not skipped
430433
assertThat(page.url()).contains("/consent");
431434
}
435+
436+
@Test
437+
public void denyConsentRedirectsToClientWithAccessDeniedError() {
438+
val screenshotPathProducer =
439+
ScreenshotPathProducer.builder()
440+
.testName("denyConsentRedirectsToClientWithAccessDeniedError")
441+
.build();
442+
443+
val page = browser.newPage();
444+
445+
page.navigate(getUriToInitiateFlow().toString());
446+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("initial-load"));
447+
448+
page.locator("input[name=loginEmail]").fill("foo@bar.com");
449+
page.locator("input[name=loginPassword]").fill("password");
450+
451+
page.locator("input[name=submit]").click();
452+
453+
page.waitForLoadState();
454+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-login-submit"));
455+
456+
page.locator("input[id=reject]").click();
457+
458+
page.waitForLoadState();
459+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-consent-deny"));
460+
461+
// The browser lands on the client's callback carrying a real OAuth error instead of a code.
462+
assertThat(page.url()).contains("/client/callback?error=access_denied");
463+
464+
val queryString = getCallbackQueryString();
465+
assertThat(queryString).contains("error=access_denied");
466+
assertThat(queryString).doesNotContain("code=");
467+
}
432468
}
433469

434470
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)

0 commit comments

Comments
 (0)