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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> scopes) {
String consentChallenge, Boolean remember, List<String> scopes, String deny) {

/**
* This field is implemented in HTML as a checkbox. When a checkbox element is not checked in a
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
};
}

Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
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 {}

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

record Accepted(String redirectTo) implements ConsentResponse {}

record Denied(String redirectTo) implements ConsentResponse {}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
4 changes: 2 additions & 2 deletions reference-app/src/main/resources/templates/consent.ftlh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
<label for="remember">Remember me:</label>
<input type="checkbox" id="remember" name="remember" checked /><br>

<input type="submit" id="accept" name="submit" value="Allow access" />
<input type="submit" id="reject" name="submit" value="Deny access" />
<input type="submit" id="accept" name="accept" value="Allow access" />
<input type="submit" id="reject" name="deny" value="Deny access" />
</form>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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=", ""))
Expand Down Expand Up @@ -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)
Expand Down