Skip to content

Commit 8977293

Browse files
authored
feat: support remember-me on login and fix the login skip redirect (#185)
* feat: support remember-me on login and fix the login skip redirect * docs: explain the subject's stability requirement * docs: sharpen the checkbox-binding and login-skip comments
1 parent d76af38 commit 8977293

8 files changed

Lines changed: 114 additions & 13 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ public record ConsentForm(
77
String consentChallenge, Boolean remember, List<String> scopes, String deny) {
88

99
/**
10-
* This field is implemented in HTML as a checkbox. When a checkbox element is not checked in a
11-
* form it is not submitted in the form at all. This provides a useful null safe getter.
10+
* Unchecked HTML checkboxes are omitted from the form post entirely — there is no {@code
11+
* remember=false} — so Spring binds this as {@code null} when the box is unchecked. The boxed
12+
* {@code Boolean} and this getter exist to absorb that.
1213
*/
1314
public boolean isRemember() {
1415
if (remember == null) {
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 AcceptLoginRequest(
8+
@NonNull String loginChallenge, @NonNull String subject, boolean remember) {}

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,12 @@ public Optional<OAuth2LoginRequest> getLoginRequest(@NonNull String loginChallen
5858
}
5959
}
6060

61-
public OAuth2RedirectTo acceptLoginRequest(
62-
@NonNull String loginChallenge, @NonNull String loginEmail) {
63-
val acceptLoginRequest = new AcceptOAuth2LoginRequest();
64-
// Subject is an alias for user ID. A subject can be a random string, a UUID, an email address,
65-
// ...
66-
acceptLoginRequest.subject(loginEmail);
61+
public OAuth2RedirectTo acceptLoginRequest(@NonNull AcceptLoginRequest acceptLoginRequest) {
62+
val acceptOAuth2LoginRequest = OryHydraRequestMapper.map(acceptLoginRequest);
63+
6764
try {
68-
return oAuth2Api().acceptOAuth2LoginRequest(loginChallenge, acceptLoginRequest);
65+
return oAuth2Api()
66+
.acceptOAuth2LoginRequest(acceptLoginRequest.loginChallenge(), acceptOAuth2LoginRequest);
6967
} catch (ApiException e) {
7068
switch (e.getCode()) {
7169
case 400, 401, 404, 500 ->

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

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

89
public class OryHydraRequestMapper {
910

1011
private static final Long DEFAULT_SESSION_EXPIRATION_IN_SECONDS = 3600L;
1112

13+
public static AcceptOAuth2LoginRequest map(@NonNull final AcceptLoginRequest acceptLoginRequest) {
14+
// The subject becomes the `sub` claim in every token Hydra issues — it is the stable, unique
15+
// identifier for the end user. Any string works, but it should never change or be reassigned.
16+
// This demo uses the login email for readability; a production login app should prefer an
17+
// immutable internal user ID (emails change and get recycled).
18+
return new AcceptOAuth2LoginRequest()
19+
.subject(acceptLoginRequest.subject())
20+
.remember(acceptLoginRequest.remember())
21+
.rememberFor(DEFAULT_SESSION_EXPIRATION_IN_SECONDS);
22+
}
23+
1224
public static AcceptOAuth2ConsentRequest map(
1325
@NonNull final AcceptConsentRequest acceptConsentRequest) {
1426
return new AcceptOAuth2ConsentRequest()
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
11
package com.ardetrick.oryhydrareference.login;
22

3-
public record LoginForm(String loginEmail, String loginPassword, String loginChallenge) {}
3+
public record LoginForm(
4+
String loginEmail, String loginPassword, String loginChallenge, Boolean remember) {
5+
6+
/**
7+
* Unchecked HTML checkboxes are omitted from the form post entirely — there is no {@code
8+
* remember=false} — so Spring binds this as {@code null} when the box is unchecked. The boxed
9+
* {@code Boolean} and this getter exist to absorb that.
10+
*/
11+
public boolean isRemember() {
12+
if (remember == null) {
13+
return false;
14+
}
15+
return remember;
16+
}
17+
}

reference-app/src/main/java/com/ardetrick/oryhydrareference/login/LoginService.java

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

3+
import com.ardetrick.oryhydrareference.hydra.AcceptLoginRequest;
34
import com.ardetrick.oryhydrareference.hydra.HydraAdminClient;
45
import com.ardetrick.oryhydrareference.login.LoginResult.LoginAcceptedFollowRedirect;
56
import com.ardetrick.oryhydrareference.login.LoginResult.LoginDeniedInvalidCredentials;
@@ -31,8 +32,21 @@ public LoginResult processInitialLoginRequest(@NonNull String loginChallenge) {
3132
val loginRequest = maybeLoginRequest.get();
3233

3334
if (loginRequest.getSkip()) {
34-
hydraAdminClient.acceptLoginRequest(loginChallenge, loginRequest.getSubject());
35-
return new LoginAcceptedFollowRedirect(loginRequest.getRequestUrl());
35+
// skip=true means Hydra recognized an existing remembered login session (only a prior
36+
// accept with remember=true creates one), so accept without showing the login UI.
37+
// remember=false does not end that session — it declines to extend it, matching Ory's
38+
// reference implementation; sliding-session behavior is available via
39+
// extend_session_lifespan=true instead. Follow the accept response's redirect:
40+
// redirecting back to the original request URL would replay the authorization request
41+
// and loop straight back here.
42+
val completedRequest =
43+
hydraAdminClient.acceptLoginRequest(
44+
AcceptLoginRequest.builder()
45+
.loginChallenge(loginChallenge)
46+
.subject(loginRequest.getSubject())
47+
.remember(false)
48+
.build());
49+
return new LoginAcceptedFollowRedirect(completedRequest.getRedirectTo());
3650
}
3751

3852
return new LoginNotSkippableDisplayLoginUI(loginChallenge);
@@ -51,7 +65,12 @@ public LoginResult processSubmittedLoginRequest(
5165
}
5266

5367
val completedRequest =
54-
hydraAdminClient.acceptLoginRequest(loginChallenge, loginForm.loginEmail());
68+
hydraAdminClient.acceptLoginRequest(
69+
AcceptLoginRequest.builder()
70+
.loginChallenge(loginChallenge)
71+
.subject(loginForm.loginEmail())
72+
.remember(loginForm.isRemember())
73+
.build());
5574

5675
return new LoginAcceptedFollowRedirect(completedRequest.getRedirectTo());
5776
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
<label for="loginPassword">Password:</label>
1414
<input type="password" name="loginPassword" placeholder="password" /><br>
1515

16+
<label for="remember">Remember me:</label>
17+
<input type="checkbox" id="remember" name="remember" /><br>
18+
1619
<input type="submit" name="submit" value="Log in" />
1720
</form>
1821

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,52 @@ public void denyConsentRedirectsToClientWithAccessDeniedError() {
464464
assertThat(queryString).contains("error=access_denied");
465465
assertThat(queryString).doesNotContain("code=");
466466
}
467+
468+
@Test
469+
public void skipLoginScreenOnSecondFlowWhenLoginRememberMeIsUsed() {
470+
val screenshotPathProducer =
471+
ScreenshotPathProducer.builder()
472+
.testName("skipLoginScreenOnSecondFlowWhenLoginRememberMeIsUsed")
473+
.build();
474+
475+
val page = browser.newPage();
476+
477+
page.navigate(getUriToInitiateFlow().toString());
478+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("initial-load"));
479+
480+
page.locator("input[name=loginEmail]").fill("foo@bar.com");
481+
page.locator("input[name=loginPassword]").fill("password");
482+
page.locator("input[id=remember]").check();
483+
484+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-check-remember"));
485+
486+
page.locator("input[name=submit]").click();
487+
488+
page.waitForLoadState();
489+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-login-submit"));
490+
491+
// Uncheck the consent-side remember so the second flow isolates the login skip: consent must
492+
// be asked again, proving the login screen alone was skipped.
493+
page.locator("input[id=remember]").uncheck();
494+
495+
page.locator("input[id=accept]").click();
496+
497+
page.waitForLoadState();
498+
page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("after-consent-submit"));
499+
500+
val code = getCodeFromCallbackCaptor();
501+
exchangeCode(code);
502+
503+
page.navigate(getUriToInitiateFlow().toString());
504+
505+
page.waitForLoadState();
506+
page.screenshot(
507+
screenshotPathProducer.screenshotOptionsForStepName("initial-load-second-time"));
508+
509+
// The login screen is skipped — no credentials were entered this time — but the consent
510+
// screen still appears because its remember was unchecked.
511+
assertThat(page.url()).contains("/consent");
512+
}
467513
}
468514

469515
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)

0 commit comments

Comments
 (0)