Merge pull request 'feat(cognito): show the client's identity providers on the hosted login page' (#3) from feat/cognito-hosted-login-providers into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-10-24 16:18:07 +00:00
3 changed files with 84 additions and 21 deletions
+4 -1
View File
@@ -316,7 +316,10 @@ emulator-friendly client-credentials flow:
Without `identity_provider`, or with `identity_provider=COGNITO`, the pool's own users sign in Without `identity_provider`, or with `identity_provider=COGNITO`, the pool's own users sign in
on the hosted login page; the client must list `COGNITO` in `SupportedIdentityProviders` (the on the hosted login page; the client must list `COGNITO` in `SupportedIdentityProviders` (the
default when none are given). default when none are given).
- `GET /cognito-idp/login` shows a user name (or email) and password form. A correct password - `GET /cognito-idp/login` shows a user name (or email) and password form when the client lists
`COGNITO`, and a "Continue with <provider>" button for each of its other
`SupportedIdentityProviders`, which starts the federated sign-in above with the same authorize
parameters. A correct password
redirects to the registered callback with a one-time authorization code and `state`, and starts redirects to the registered callback with a one-time authorization code and `state`, and starts
a one-hour browser session (the `cognito` cookie), so the next authorize request returns a code a one-hour browser session (the `cognito` cookie), so the next authorize request returns a code
without the form unless it sends `prompt=login`. Wrong passwords, unknown users and without the form unless it sends `prompt=login`. Wrong passwords, unknown users and
@@ -50,6 +50,7 @@ public class CognitoHostedLoginController {
private static final Duration SESSION_LIFETIME = Duration.ofHours(1); private static final Duration SESSION_LIFETIME = Duration.ofHours(1);
private static final Duration AUTHORIZATION_CODE_LIFETIME = Duration.ofMinutes(5); private static final Duration AUTHORIZATION_CODE_LIFETIME = Duration.ofMinutes(5);
private static final String COGNITO_PROVIDER = "COGNITO"; private static final String COGNITO_PROVIDER = "COGNITO";
private static final String NO_PROVIDERS = "This app client has no identity providers to sign in with.";
private final CognitoService cognitoService; private final CognitoService cognitoService;
private final CognitoFederationStateStore stateStore; private final CognitoFederationStateStore stateStore;
@@ -83,16 +84,16 @@ public class CognitoHostedLoginController {
} }
/** /**
* Continues an authorize request for the pool's own users: an existing session gets a code * Continues an authorize request that names no external provider: an existing session gets a
* straight away, anyone else is sent to the login page. * code straight away, anyone else is sent to the login page, which offers the pool's own
* password form and a button for each of the client's other identity providers.
*/ */
Response authorizeWithCognito(ContainerRequestContext requestContext, UserPoolClient client, Response authorizeWithCognito(ContainerRequestContext requestContext, UserPoolClient client,
AuthorizeRequest request, String prompt, String sessionId) { AuthorizeRequest request, String prompt, String sessionId) {
if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) { if (!offersCognito(client) && externalProviders(client).isEmpty()) {
return errorPage("This app client does not offer sign-in with a user name and password " return errorPage(NO_PROVIDERS);
+ "(COGNITO is not one of its identity providers).");
} }
if (!"login".equals(prompt)) { if (offersCognito(client) && !"login".equals(prompt)) {
Optional<CognitoUser> signedIn = sessionUser(client, sessionId); Optional<CognitoUser> signedIn = sessionUser(client, sessionId);
if (signedIn.isPresent()) { if (signedIn.isPresent()) {
return redirectWithCode(client, request, signedIn.get(), null); return redirectWithCode(client, request, signedIn.get(), null);
@@ -121,7 +122,7 @@ public class CognitoHostedLoginController {
return error; return error;
} }
String csrf = randomToken(); String csrf = randomToken();
return loginForm((UserPoolClient) validated, request, csrf, null, null, 200) return loginForm(requestContext, (UserPoolClient) validated, request, csrf, null, null, 200)
.cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1)) .cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1))
.build(); .build();
} }
@@ -141,6 +142,10 @@ public class CognitoHostedLoginController {
return error; return error;
} }
UserPoolClient client = (UserPoolClient) validated; UserPoolClient client = (UserPoolClient) validated;
if (!offersCognito(client)) {
return errorPage("This app client does not offer sign-in with a user name and password "
+ "(COGNITO is not one of its identity providers).");
}
String csrf = form.getFirst("_csrf"); String csrf = form.getFirst("_csrf");
if (csrf == null || csrfCookie == null || !constantTimeEquals(csrf, csrfCookie)) { if (csrf == null || csrfCookie == null || !constantTimeEquals(csrf, csrfCookie)) {
return errorPage("Your sign-in form expired. Go back to the app and sign in again."); return errorPage("Your sign-in form expired. Go back to the app and sign in again.");
@@ -149,14 +154,14 @@ public class CognitoHostedLoginController {
String username = trimToNull(form.getFirst("username")); String username = trimToNull(form.getFirst("username"));
String password = form.getFirst("password"); String password = form.getFirst("password");
if (username == null || password == null || password.isEmpty()) { if (username == null || password == null || password.isEmpty()) {
return loginForm(client, request, csrf, username, "Enter your user name and password.", 400).build(); return loginForm(requestContext, client, request, csrf, username, "Enter your user name and password.", 400).build();
} }
CognitoUser user; CognitoUser user;
try { try {
user = cognitoService.authenticateHostedLogin(client, username, password); user = cognitoService.authenticateHostedLogin(client, username, password);
} catch (AwsException e) { } catch (AwsException e) {
return loginForm(client, request, csrf, username, describe(e), 400).build(); return loginForm(requestContext, client, request, csrf, username, describe(e), 400).build();
} }
String sessionId = stateStore.putHostedSession(new CognitoHostedSession( String sessionId = stateStore.putHostedSession(new CognitoHostedSession(
@@ -253,9 +258,8 @@ public class CognitoHostedLoginController {
if (!client.getCallbackURLs().contains(request.redirectUri())) { if (!client.getCallbackURLs().contains(request.redirectUri())) {
return errorPage("redirect_uri is not registered for this app client."); return errorPage("redirect_uri is not registered for this app client.");
} }
if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) { if (!offersCognito(client) && externalProviders(client).isEmpty()) {
return errorPage("This app client does not offer sign-in with a user name and password " return errorPage(NO_PROVIDERS);
+ "(COGNITO is not one of its identity providers).");
} }
String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod()); String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod());
if (pkceError != null) { if (pkceError != null) {
@@ -264,6 +268,16 @@ public class CognitoHostedLoginController {
return client; return client;
} }
private static boolean offersCognito(UserPoolClient client) {
return client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER);
}
private static List<String> externalProviders(UserPoolClient client) {
return client.getSupportedIdentityProviders().stream()
.filter(provider -> !COGNITO_PROVIDER.equals(provider))
.toList();
}
static String pkceError(String codeChallenge, String codeChallengeMethod) { static String pkceError(String codeChallenge, String codeChallengeMethod) {
if (codeChallenge == null) { if (codeChallenge == null) {
return codeChallengeMethod == null ? null : "code_challenge_method was sent without code_challenge."; return codeChallengeMethod == null ? null : "code_challenge_method was sent without code_challenge.";
@@ -300,8 +314,9 @@ public class CognitoHostedLoginController {
}; };
} }
private Response.ResponseBuilder loginForm(UserPoolClient client, AuthorizeRequest request, String csrf, private Response.ResponseBuilder loginForm(ContainerRequestContext requestContext, UserPoolClient client,
String username, String error, int status) { AuthorizeRequest request, String csrf, String username,
String error, int status) {
UserPool pool = cognitoService.describeUserPool(client.getUserPoolId()); UserPool pool = cognitoService.describeUserPool(client.getUserPoolId());
boolean email = pool.getUsernameAttributes() != null && pool.getUsernameAttributes().contains("email"); boolean email = pool.getUsernameAttributes() != null && pool.getUsernameAttributes().contains("email");
StringBuilder hidden = new StringBuilder(); StringBuilder hidden = new StringBuilder();
@@ -312,19 +327,32 @@ public class CognitoHostedLoginController {
hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">"); hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
String appName = client.getClientName() == null || client.getClientName().isBlank() String appName = client.getClientName() == null || client.getClientName().isBlank()
? pool.getName() : client.getClientName(); ? pool.getName() : client.getClientName();
String body = """ // Each external provider starts the federated flow with the same authorize parameters.
<h1>Sign in</h1> StringBuilder providers = new StringBuilder();
<p class="muted">to continue to <strong>%s</strong></p> for (String provider : externalProviders(client)) {
%s Map<String, String> query = new LinkedHashMap<>(request.asQuery());
query.put("identity_provider", provider);
providers.append("<a class=\"provider\" href=\"").append(escape(authorizePath(requestContext) + "?" + query(query)))
.append("\">Continue with ").append(escape(provider)).append("</a>");
}
String passwordForm = !offersCognito(client) ? "" : """
<form method="post" action=""> <form method="post" action="">
%s %s
<label>%s<input name="username" type="%s" autocomplete="username" value="%s" required autofocus></label> <label>%s<input name="username" type="%s" autocomplete="username" value="%s" required autofocus></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label> <label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
""".formatted(hidden, email ? "Email" : "User name", email ? "email" : "text",
escape(username == null ? "" : username));
String divider = providers.isEmpty() || passwordForm.isEmpty() ? "" : "<p class=\"divider\"><span>or</span></p>";
String body = """
<h1>Sign in</h1>
<p class="muted">to continue to <strong>%s</strong></p>
%s
%s%s<div class="providers">%s</div>
""".formatted(escape(appName), """.formatted(escape(appName),
error == null ? "" : "<p class=\"error\" role=\"alert\">" + escape(error) + "</p>", error == null ? "" : "<p class=\"error\" role=\"alert\">" + escape(error) + "</p>",
hidden, email ? "Email" : "User name", email ? "email" : "text", escape(username == null ? "" : username)); passwordForm, divider, providers);
return Response.status(status).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8")) return Response.status(status).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"))
.entity(page("Sign in", body)) .entity(page("Sign in", body))
.header("Cache-Control", "no-store") .header("Cache-Control", "no-store")
@@ -369,6 +397,11 @@ public class CognitoHostedLoginController {
input { font:inherit; padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); } input { font:inherit; padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); }
input:focus { outline:2px solid var(--accent); outline-offset:1px; } input:focus { outline:2px solid var(--accent); outline-offset:1px; }
button { font:inherit; font-weight:600; padding:10px 12px; border:0; border-radius:6px; background:var(--accent); color:var(--on-accent); cursor:pointer; } button { font:inherit; font-weight:600; padding:10px 12px; border:0; border-radius:6px; background:var(--accent); color:var(--on-accent); cursor:pointer; }
.providers { display:grid; gap:10px; }
.provider { display:block; padding:10px 12px; border:1px solid var(--border); border-radius:6px; color:var(--text); font-weight:600; text-align:center; text-decoration:none; overflow-wrap:anywhere; }
.provider:hover { border-color:var(--accent); color:var(--accent); }
.divider { display:flex; align-items:center; gap:10px; margin:16px 0; color:var(--muted); font-size:13px; }
.divider::before, .divider::after { content:""; flex:1; border-top:1px solid var(--border); }
.error { margin:0 0 16px; padding:10px 12px; border-radius:6px; background:var(--error-bg); color:var(--error); overflow-wrap:anywhere; } .error { margin:0 0 16px; padding:10px 12px; border-radius:6px; background:var(--error-bg); color:var(--error); overflow-wrap:anywhere; }
footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; } footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; }
</style> </style>
@@ -391,6 +424,11 @@ public class CognitoHostedLoginController {
return "https".equalsIgnoreCase(requestContext.getUriInfo().getRequestUri().getScheme()); return "https".equalsIgnoreCase(requestContext.getUriInfo().getRequestUri().getScheme());
} }
private static String authorizePath(ContainerRequestContext requestContext) {
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
return customDomain ? "/oauth2/authorize" : "/cognito-idp/oauth2/authorize";
}
/** Custom domains serve the pages at /login and /logout; everything else under /cognito-idp/. */ /** Custom domains serve the pages at /login and /logout; everything else under /cognito-idp/. */
private static String pagePath(ContainerRequestContext requestContext, String page) { private static String pagePath(ContainerRequestContext requestContext, String page) {
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null; boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
@@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -171,11 +172,32 @@ class CognitoHostedLoginIntegrationTest {
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"], "AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"],
"SupportedIdentityProviders":["CompanyIdp"]} "SupportedIdentityProviders":["CompanyIdp"]}
""".formatted(poolId, CALLBACK)).path("UserPoolClient").path("ClientId").asText(); """.formatted(poolId, CALLBACK)).path("UserPoolClient").path("ClientId").asText();
given().redirects().follow(false) String federatedLogin = given().redirects().follow(false)
.queryParam("client_id", federatedOnly).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code") .queryParam("client_id", federatedOnly).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
.queryParam("state", "app-state")
.when().get("/cognito-idp/oauth2/authorize") .when().get("/cognito-idp/oauth2/authorize")
.then().statusCode(302).extract().header("Location");
String federatedPage = given().redirects().follow(false).urlEncodingEnabled(false).get(federatedLogin)
.then().statusCode(200).extract().asString();
assertTrue(federatedPage.contains(">Continue with CompanyIdp</a>"), federatedPage);
assertTrue(federatedPage.contains("identity_provider=CompanyIdp"), "the button starts the federated flow");
assertTrue(federatedPage.contains("state=app-state"), "the button keeps the app's authorize parameters");
assertFalse(federatedPage.contains("name=\"password\""), "no password form without COGNITO");
given().redirects().follow(false)
.formParam("client_id", federatedOnly).formParam("redirect_uri", CALLBACK).formParam("response_type", "code")
.formParam("username", email).formParam("password", PASSWORD)
.when().post("/cognito-idp/login")
.then().statusCode(400).body(containsString("COGNITO is not one of its identity providers")); .then().statusCode(400).body(containsString("COGNITO is not one of its identity providers"));
cognitoJson("UpdateUserPoolClient", """
{"UserPoolId":"%s","ClientId":"%s","ClientName":"hosted-web","AllowedOAuthFlowsUserPoolClient":true,
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid","email"],
"CallbackURLs":["%s"],"LogoutURLs":["%s"],"SupportedIdentityProviders":["COGNITO","CompanyIdp"],
"ExplicitAuthFlows":["ALLOW_REFRESH_TOKEN_AUTH"]}
""".formatted(poolId, clientId, CALLBACK, SIGNED_OUT));
String both = loginPage().asString();
assertTrue(both.contains("name=\"password\"") && both.contains(">Continue with CompanyIdp</a>"), both);
given().redirects().follow(false) given().redirects().follow(false)
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code") .queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
.queryParam("code_challenge", "abc").queryParam("code_challenge_method", "plain") .queryParam("code_challenge", "abc").queryParam("code_challenge_method", "plain")