feat(cognito): hosted login page, PKCE and refresh-token grant
The pool's own users can now sign in through /oauth2/authorize the way apps expect Cognito to work: without identity_provider (or with COGNITO) the browser lands on a hosted login page, and a correct password redirects to the registered callback with a one-time code. - GET/POST /cognito-idp/login (and /login on custom domains), with a CSRF cookie and the same password rules and Pre/Post Authentication triggers as USER_PASSWORD_AUTH; wrong passwords, unknown and unconfirmed users get the same answer until the password is right - a one-hour browser session, so the next authorize skips the form unless it sends prompt=login, and /logout with LogoutURLs validation - PKCE S256 on both hosted and federated codes, and nonce in the ID token - refresh_token grant on /oauth2/token - codes now fire Pre Token Generation with TokenGeneration_HostedAuth instead of skipping the trigger
This commit is contained in:
+14
@@ -36,6 +36,20 @@ class CognitoCustomDomainFilterTest {
|
||||
verify(request).setRequestUri(URI.create("http://" + DOMAIN + ":4566/cognito-idp/oauth2/token?x=1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheHostedLoginAndLogoutPagesOnACustomDomain() {
|
||||
when(cognitoService.findCustomDomain(DOMAIN)).thenReturn(Optional.of(domain("us-east-1_abc")));
|
||||
ContainerRequestContext login = request("https://" + DOMAIN + "/login?client_id=c", DOMAIN);
|
||||
ContainerRequestContext logout = request("https://" + DOMAIN + "/logout?client_id=c", DOMAIN);
|
||||
|
||||
filter.filter(login);
|
||||
filter.filter(logout);
|
||||
|
||||
verify(login).setRequestUri(URI.create("https://" + DOMAIN + "/cognito-idp/login?client_id=c"));
|
||||
verify(logout).setRequestUri(URI.create("https://" + DOMAIN + "/cognito-idp/logout?client_id=c"));
|
||||
verify(login).setProperty(CognitoCustomDomainFilter.POOL_PROPERTY, "us-east-1_abc");
|
||||
}
|
||||
|
||||
/** HTTP/2 carries no Host header; the authority is on the request URI. */
|
||||
@Test
|
||||
void fallsBackToTheRequestAuthorityWithoutAHostHeader() {
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package io.github.hectorvent.floci.services.cognito;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static io.github.hectorvent.floci.services.cognito.CognitoRestAssuredUtils.cognitoJson;
|
||||
import static io.restassured.RestAssured.given;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@QuarkusTest
|
||||
class CognitoHostedLoginIntegrationTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final String CALLBACK = "https://app.example.test/callback";
|
||||
private static final String SIGNED_OUT = "https://app.example.test/signed-out";
|
||||
private static final String PASSWORD = "Passw0rd!";
|
||||
private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUWU1p1r_wW1gFWFOEjXk-local-verifier";
|
||||
|
||||
private String poolId;
|
||||
private String clientId;
|
||||
private String email;
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createPoolClientAndUser() throws Exception {
|
||||
poolId = cognitoJson("CreateUserPool", """
|
||||
{"PoolName":"HostedLogin","UsernameAttributes":["email"],"AutoVerifiedAttributes":["email"]}
|
||||
""").path("UserPool").path("Id").asText();
|
||||
clientId = cognitoJson("CreateUserPoolClient", """
|
||||
{"UserPoolId":"%s","ClientName":"hosted-web","AllowedOAuthFlowsUserPoolClient":true,
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid","email"],
|
||||
"CallbackURLs":["%s"],"LogoutURLs":["%s"],"SupportedIdentityProviders":["COGNITO"],
|
||||
"ExplicitAuthFlows":["ALLOW_REFRESH_TOKEN_AUTH"]}
|
||||
""".formatted(poolId, CALLBACK, SIGNED_OUT)).path("UserPoolClient").path("ClientId").asText();
|
||||
email = "hosted-" + System.nanoTime() + "@example.test";
|
||||
cognitoJson("AdminCreateUser", """
|
||||
{"UserPoolId":"%s","Username":"%s","MessageAction":"SUPPRESS",
|
||||
"UserAttributes":[{"Name":"email","Value":"%s"},{"Name":"email_verified","Value":"true"}]}
|
||||
""".formatted(poolId, email, email));
|
||||
cognitoJson("AdminSetUserPassword", """
|
||||
{"UserPoolId":"%s","Username":"%s","Password":"%s","Permanent":true}
|
||||
""".formatted(poolId, email, PASSWORD));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsInOnTheHostedPageAndRedeemsThePkceCodeForTokens() throws Exception {
|
||||
String loginLocation = authorize(null, "openid email", "nonce-123")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(loginLocation).getPath().endsWith("/cognito-idp/login"), loginLocation);
|
||||
assertEquals("app-state", queryParameter(loginLocation, "state"));
|
||||
|
||||
Response page = given().redirects().follow(false).urlEncodingEnabled(false).get(loginLocation)
|
||||
.then().statusCode(200)
|
||||
.header("Content-Type", containsString("text/html"))
|
||||
.body(containsString("to continue to <strong>hosted-web</strong>"))
|
||||
.body(containsString("type=\"email\""))
|
||||
.extract().response();
|
||||
String csrf = page.getCookie(CognitoHostedLoginController.CSRF_COOKIE);
|
||||
assertNotNull(csrf);
|
||||
assertEquals(csrf, hiddenValue(page.asString(), "_csrf"));
|
||||
|
||||
Response signedIn = submit(page, email, PASSWORD).then().statusCode(302).extract().response();
|
||||
String callback = signedIn.getHeader("Location");
|
||||
assertTrue(callback.startsWith(CALLBACK + "?code="), callback);
|
||||
assertEquals("app-state", queryParameter(callback, "state"));
|
||||
assertTrue(signedIn.getDetailedCookie(CognitoHostedLoginController.SESSION_COOKIE).isHttpOnly());
|
||||
|
||||
given().formParam("grant_type", "authorization_code").formParam("client_id", clientId)
|
||||
.formParam("code", queryParameter(callback, "code")).formParam("redirect_uri", CALLBACK)
|
||||
.formParam("code_verifier", VERIFIER + "-wrong")
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(400).body("error", equalTo("invalid_grant"));
|
||||
|
||||
String sessionId = signedIn.getCookie(CognitoHostedLoginController.SESSION_COOKIE);
|
||||
String secondCallback = authorize(sessionId, "openid email", "nonce-456")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(secondCallback.startsWith(CALLBACK + "?code="), "an open session skips the form: " + secondCallback);
|
||||
|
||||
Response tokens = given().formParam("grant_type", "authorization_code").formParam("client_id", clientId)
|
||||
.formParam("code", queryParameter(secondCallback, "code")).formParam("redirect_uri", CALLBACK)
|
||||
.formParam("code_verifier", VERIFIER)
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(200).body("token_type", equalTo("Bearer")).extract().response();
|
||||
JsonNode idToken = claims(tokens.path("id_token"));
|
||||
assertEquals("nonce-456", idToken.path("nonce").asText());
|
||||
assertEquals(email, idToken.path("email").asText());
|
||||
assertEquals("id", idToken.path("token_use").asText());
|
||||
|
||||
given().formParam("grant_type", "refresh_token").formParam("client_id", clientId)
|
||||
.formParam("refresh_token", tokens.path("refresh_token").toString())
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(200).body("access_token", not(equalTo(tokens.path("access_token").toString())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPasswordUnknownUserAndMissingCsrfAreRefused() {
|
||||
Response page = loginPage();
|
||||
submit(page, email, "Wrong-passw0rd").then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."))
|
||||
.body(containsString("value=\"" + email + "\""));
|
||||
submit(page, "nobody@example.test", PASSWORD).then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."));
|
||||
|
||||
given().redirects().follow(false)
|
||||
.formParam("client_id", clientId).formParam("redirect_uri", CALLBACK).formParam("response_type", "code")
|
||||
.formParam("username", email).formParam("password", PASSWORD)
|
||||
.when().post("/cognito-idp/login")
|
||||
.then().statusCode(400).body(containsString("Your sign-in form expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptLoginShowsTheFormAndLogoutEndsTheSession() {
|
||||
Response signedIn = submit(loginPage(), email, PASSWORD).then().statusCode(302).extract().response();
|
||||
String sessionId = signedIn.getCookie(CognitoHostedLoginController.SESSION_COOKIE);
|
||||
|
||||
String forced = given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK)
|
||||
.queryParam("response_type", "code").queryParam("prompt", "login")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(forced).getPath().endsWith("/cognito-idp/login"), forced);
|
||||
|
||||
given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("logout_uri", "https://evil.example.test/")
|
||||
.when().get("/cognito-idp/logout")
|
||||
.then().statusCode(400).body(containsString("not registered"));
|
||||
|
||||
Response loggedOut = given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("logout_uri", SIGNED_OUT)
|
||||
.when().get("/cognito-idp/logout")
|
||||
.then().statusCode(302).header("Location", equalTo(SIGNED_OUT)).extract().response();
|
||||
assertEquals(0, loggedOut.getDetailedCookie(CognitoHostedLoginController.SESSION_COOKIE).getMaxAge());
|
||||
|
||||
String afterLogout = authorize(sessionId, "openid", null).then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(afterLogout).getPath().endsWith("/cognito-idp/login"), afterLogout);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientsWithoutCognitoAsAProviderAndBadPkceAreRejected() throws Exception {
|
||||
cognitoJson("CreateIdentityProvider", """
|
||||
{"UserPoolId":"%s","ProviderName":"CompanyIdp","ProviderType":"OIDC",
|
||||
"ProviderDetails":{"authorize_url":"https://idp.example.test/authorize","token_url":"https://idp.example.test/token",
|
||||
"attributes_url":"https://idp.example.test/userinfo","client_id":"idp-client","client_secret":"idp-secret"},
|
||||
"AttributeMapping":{"email":"email","username":"sub"}}
|
||||
""".formatted(poolId));
|
||||
String federatedOnly = cognitoJson("CreateUserPoolClient", """
|
||||
{"UserPoolId":"%s","ClientName":"federated-only","AllowedOAuthFlowsUserPoolClient":true,
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"],
|
||||
"SupportedIdentityProviders":["CompanyIdp"]}
|
||||
""".formatted(poolId, CALLBACK)).path("UserPoolClient").path("ClientId").asText();
|
||||
given().redirects().follow(false)
|
||||
.queryParam("client_id", federatedOnly).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(400).body(containsString("COGNITO is not one of its identity providers"));
|
||||
|
||||
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")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(400).body("error", equalTo("invalid_request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfirmedUsersLearnWhyOnlyWithTheRightPassword() throws Exception {
|
||||
String pending = "pending-" + System.nanoTime() + "@example.test";
|
||||
cognitoJson("SignUp", """
|
||||
{"ClientId":"%s","Username":"%s","Password":"%s","UserAttributes":[{"Name":"email","Value":"%s"}]}
|
||||
""".formatted(clientId, pending, PASSWORD, pending));
|
||||
|
||||
Response page = loginPage();
|
||||
submit(page, pending, "Wrong-passw0rd").then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."));
|
||||
submit(page, pending, PASSWORD).then().statusCode(400)
|
||||
.body(containsString("not confirmed yet"));
|
||||
}
|
||||
|
||||
private Response authorize(String sessionId, String scope, String nonce) {
|
||||
var request = given().redirects().follow(false)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK)
|
||||
.queryParam("response_type", "code").queryParam("scope", scope).queryParam("state", "app-state")
|
||||
.queryParam("code_challenge", challenge(VERIFIER)).queryParam("code_challenge_method", "S256");
|
||||
if (nonce != null) {
|
||||
request.queryParam("nonce", nonce);
|
||||
}
|
||||
if (sessionId != null) {
|
||||
request.cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId);
|
||||
}
|
||||
return request.when().get("/cognito-idp/oauth2/authorize");
|
||||
}
|
||||
|
||||
private Response loginPage() {
|
||||
return given().redirects().follow(false)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.queryParam("state", "app-state")
|
||||
.when().get("/cognito-idp/login")
|
||||
.then().statusCode(200).extract().response();
|
||||
}
|
||||
|
||||
private Response submit(Response page, String username, String password) {
|
||||
String html = page.asString();
|
||||
var request = given().redirects().follow(false)
|
||||
.cookie(CognitoHostedLoginController.CSRF_COOKIE, page.getCookie(CognitoHostedLoginController.CSRF_COOKIE))
|
||||
.formParam("username", username).formParam("password", password);
|
||||
Matcher hidden = Pattern.compile("<input type=\"hidden\" name=\"([^\"]+)\" value=\"([^\"]*)\">").matcher(html);
|
||||
while (hidden.find()) {
|
||||
request.formParam(hidden.group(1), unescape(hidden.group(2)));
|
||||
}
|
||||
return request.when().post("/cognito-idp/login");
|
||||
}
|
||||
|
||||
private static String hiddenValue(String html, String name) {
|
||||
Matcher matcher = Pattern.compile("name=\"" + Pattern.quote(name) + "\" value=\"([^\"]*)\"").matcher(html);
|
||||
return matcher.find() ? unescape(matcher.group(1)) : null;
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
return value.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||
.replace("&", "&");
|
||||
}
|
||||
|
||||
private static String challenge(String verifier) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode claims(String jwt) throws Exception {
|
||||
return OBJECT_MAPPER.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]));
|
||||
}
|
||||
|
||||
private static String queryParameter(String location, String name) {
|
||||
String query = URI.create(location).getRawQuery();
|
||||
if (query == null) {
|
||||
return null;
|
||||
}
|
||||
for (String parameter : query.split("&")) {
|
||||
String[] pair = parameter.split("=", 2);
|
||||
if (pair[0].equals(name)) {
|
||||
return URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -127,7 +127,7 @@ class CognitoOAuthControllerTest {
|
||||
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
||||
when(cognitoService.generateAuthResult(any(CognitoUser.class), any(UserPool.class), eq(client), eq(null)))
|
||||
when(cognitoService.issueHostedTokens(eq(client), any(CognitoUser.class), eq(null)))
|
||||
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
||||
|
||||
@@ -226,7 +226,7 @@ class CognitoOAuthControllerTest {
|
||||
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
||||
when(cognitoService.generateAuthResult(any(CognitoUser.class), any(UserPool.class), eq(client), eq(null)))
|
||||
when(cognitoService.issueHostedTokens(eq(client), any(CognitoUser.class), eq(null)))
|
||||
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
||||
return code;
|
||||
|
||||
+1
-1
@@ -373,7 +373,7 @@ class CognitoOAuthTokenIntegrationTest {
|
||||
@Order(16)
|
||||
void unsupportedGrantTypeReturnsUnsupportedGrantType() {
|
||||
given()
|
||||
.formParam("grant_type", "refresh_token")
|
||||
.formParam("grant_type", "password")
|
||||
.formParam("client_id", clientId)
|
||||
.when()
|
||||
.post("/cognito-idp/oauth2/token")
|
||||
|
||||
Reference in New Issue
Block a user