Compare commits
2 Commits
2e7ebefca9
...
e557be1bc6
| Author | SHA1 | Date | |
|---|---|---|---|
| e557be1bc6 | |||
| 174cb600d4 |
@@ -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 <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
|
||||
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
|
||||
|
||||
+57
-19
@@ -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<CognitoUser> 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<String> 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("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
|
||||
String appName = client.getClientName() == null || client.getClientName().isBlank()
|
||||
? pool.getName() : client.getClientName();
|
||||
String body = """
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">to continue to <strong>%s</strong></p>
|
||||
%s
|
||||
// Each external provider starts the federated flow with the same authorize parameters.
|
||||
StringBuilder providers = new StringBuilder();
|
||||
for (String provider : externalProviders(client)) {
|
||||
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="">
|
||||
%s
|
||||
<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>
|
||||
<button type="submit">Sign in</button>
|
||||
</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),
|
||||
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"))
|
||||
.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; }
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
+23
-1
@@ -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</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"));
|
||||
|
||||
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)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.queryParam("code_challenge", "abc").queryParam("code_challenge_method", "plain")
|
||||
|
||||
Reference in New Issue
Block a user