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:
Zakaria
2026-10-24 11:09:37 -04:00
parent e7fb8bb8d6
commit f4bbb6fab4
16 changed files with 1040 additions and 32 deletions
@@ -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() {
@@ -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("&quot;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">")
.replace("&amp;", "&");
}
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;
}
}
@@ -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;
@@ -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")