Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 174cb600d4 | |||
| 2e7ebefca9 |
@@ -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
|
||||||
|
|||||||
+57
-19
@@ -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
-1
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user