From 174cb600d46eef8fa33e032cfa87c59d12a06e39 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 24 Oct 2026 12:14:04 -0400 Subject: [PATCH] feat(cognito): show the client's identity providers on the hosted login page The hosted login page now offers a "Continue with " button for each of the app client's other SupportedIdentityProviders, carrying the same authorize parameters (state, nonce, PKCE) into the federated flow, as Cognito's managed login does. A client that lists only external providers gets the buttons without the password form instead of an error. --- docs/services/cognito.md | 5 +- .../cognito/CognitoHostedLoginController.java | 76 ++++++++++++++----- .../CognitoHostedLoginIntegrationTest.java | 24 +++++- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/docs/services/cognito.md b/docs/services/cognito.md index 4eec86a..83390c2 100644 --- a/docs/services/cognito.md +++ b/docs/services/cognito.md @@ -316,7 +316,10 @@ emulator-friendly client-credentials flow: 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 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 " 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 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 diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java index 9e6ca36..55d2d10 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java @@ -50,6 +50,7 @@ public class CognitoHostedLoginController { private static final Duration SESSION_LIFETIME = Duration.ofHours(1); private static final Duration AUTHORIZATION_CODE_LIFETIME = Duration.ofMinutes(5); 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 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 - * straight away, anyone else is sent to the login page. + * Continues an authorize request that names no external provider: an existing session gets a + * 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, AuthorizeRequest request, String prompt, String sessionId) { - if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) { - return errorPage("This app client does not offer sign-in with a user name and password " - + "(COGNITO is not one of its identity providers)."); + if (!offersCognito(client) && externalProviders(client).isEmpty()) { + return errorPage(NO_PROVIDERS); } - if (!"login".equals(prompt)) { + if (offersCognito(client) && !"login".equals(prompt)) { Optional signedIn = sessionUser(client, sessionId); if (signedIn.isPresent()) { return redirectWithCode(client, request, signedIn.get(), null); @@ -121,7 +122,7 @@ public class CognitoHostedLoginController { return error; } 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)) .build(); } @@ -141,6 +142,10 @@ public class CognitoHostedLoginController { return error; } 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"); if (csrf == null || csrfCookie == null || !constantTimeEquals(csrf, csrfCookie)) { 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 password = form.getFirst("password"); 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; try { user = cognitoService.authenticateHostedLogin(client, username, password); } 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( @@ -253,9 +258,8 @@ public class CognitoHostedLoginController { if (!client.getCallbackURLs().contains(request.redirectUri())) { return errorPage("redirect_uri is not registered for this app client."); } - if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) { - return errorPage("This app client does not offer sign-in with a user name and password " - + "(COGNITO is not one of its identity providers)."); + if (!offersCognito(client) && externalProviders(client).isEmpty()) { + return errorPage(NO_PROVIDERS); } String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod()); if (pkceError != null) { @@ -264,6 +268,16 @@ public class CognitoHostedLoginController { return client; } + private static boolean offersCognito(UserPoolClient client) { + return client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER); + } + + private static List externalProviders(UserPoolClient client) { + return client.getSupportedIdentityProviders().stream() + .filter(provider -> !COGNITO_PROVIDER.equals(provider)) + .toList(); + } + static String pkceError(String codeChallenge, String codeChallengeMethod) { if (codeChallenge == null) { 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, - String username, String error, int status) { + private Response.ResponseBuilder loginForm(ContainerRequestContext requestContext, UserPoolClient client, + AuthorizeRequest request, String csrf, String username, + String error, int status) { UserPool pool = cognitoService.describeUserPool(client.getUserPoolId()); boolean email = pool.getUsernameAttributes() != null && pool.getUsernameAttributes().contains("email"); StringBuilder hidden = new StringBuilder(); @@ -312,19 +327,32 @@ public class CognitoHostedLoginController { hidden.append(""); String appName = client.getClientName() == null || client.getClientName().isBlank() ? pool.getName() : client.getClientName(); - String body = """ -

Sign in

-

to continue to %s

- %s + // Each external provider starts the federated flow with the same authorize parameters. + StringBuilder providers = new StringBuilder(); + for (String provider : externalProviders(client)) { + Map query = new LinkedHashMap<>(request.asQuery()); + query.put("identity_provider", provider); + providers.append("Continue with ").append(escape(provider)).append(""); + } + String passwordForm = !offersCognito(client) ? "" : """
%s
+ """.formatted(hidden, email ? "Email" : "User name", email ? "email" : "text", + escape(username == null ? "" : username)); + String divider = providers.isEmpty() || passwordForm.isEmpty() ? "" : "

or

"; + String body = """ +

Sign in

+

to continue to %s

+ %s + %s%s
%s
""".formatted(escape(appName), error == null ? "" : "

" + escape(error) + "

", - 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")) .entity(page("Sign in", body)) .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: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; } + .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; } footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; } @@ -391,6 +424,11 @@ public class CognitoHostedLoginController { 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/. */ private static String pagePath(ContainerRequestContext requestContext, String page) { boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null; diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java index 4abe050..ce86075 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java @@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.not; 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.assertTrue; @@ -171,11 +172,32 @@ class CognitoHostedLoginIntegrationTest { "AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"], "SupportedIdentityProviders":["CompanyIdp"]} """.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("state", "app-state") .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"), 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")); + 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"), both); + given().redirects().follow(false) .queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code") .queryParam("code_challenge", "abc").queryParam("code_challenge_method", "plain") -- 2.52.0