From f4bbb6fab402afb0fc3855c2440ffbe939ec4a21 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 24 Oct 2026 11:09:37 -0400 Subject: [PATCH] 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 --- docs/services/cognito.md | 40 +- .../cognito/CognitoAuthFlowHandler.java | 56 +++ .../cognito/CognitoCustomDomainFilter.java | 18 +- .../cognito/CognitoFederationService.java | 12 +- .../cognito/CognitoFederationStateStore.java | 27 + .../cognito/CognitoHostedLoginController.java | 464 ++++++++++++++++++ .../cognito/CognitoOAuthController.java | 125 ++++- .../services/cognito/CognitoService.java | 8 + .../cognito/CognitoWellKnownController.java | 2 +- .../model/CognitoAuthorizationCode.java | 14 +- .../CognitoAuthorizationTransaction.java | 9 +- .../cognito/model/CognitoHostedSession.java | 7 + .../CognitoCustomDomainFilterTest.java | 14 + .../CognitoHostedLoginIntegrationTest.java | 270 ++++++++++ .../cognito/CognitoOAuthControllerTest.java | 4 +- .../CognitoOAuthTokenIntegrationTest.java | 2 +- 16 files changed, 1040 insertions(+), 32 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoHostedSession.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java diff --git a/docs/services/cognito.md b/docs/services/cognito.md index 162c78e..4eec86a 100644 --- a/docs/services/cognito.md +++ b/docs/services/cognito.md @@ -75,9 +75,9 @@ AWS-shaped `/oauth2/authorize` and `/oauth2/idpresponse` endpoints to the config OIDC provider, exchanges the returned code, provisions or reconciles the federated user, and issues a Cognito authorization code for the registered callback. -Only `ProviderType=OIDC` is supported by this flow. Floci does not provide a built-in hosted -UI and does not implement social providers such as Google, Facebook, Login with Amazon or -SignInWithApple. +Only `ProviderType=OIDC` is supported by this flow. Floci does not implement social providers +such as Google, Facebook, Login with Amazon or SignInWithApple. The pool's own users sign in on +the hosted login page instead (see Well-Known And OAuth Endpoints below). Two deliberate divergences from AWS, both consequences of not calling out to a third party: @@ -280,8 +280,8 @@ up, so an oversized request naming something that does not exist reports the req problem rather than `ResourceNotFoundException`, and an update violating both reports them in one message with the asset list first. -Branding is presentation for the hosted UI, which Floci does not serve, so it is stored -and returned rather than rendered. Two divergences follow from that: +Branding is presentation for the hosted UI. Floci's hosted login page does not apply it, so it +is stored and returned rather than rendered. Two divergences follow from that: - **`Settings` is stored opaquely.** AWS validates it against a deep schema, rejecting unknown properties with `Invalid settings provided. Validation errors: [{property: @@ -304,17 +304,37 @@ and returned rather than rendered. Two divergences follow from that: | `GET /{userPoolId}/.well-known/jwks.json` | JSON Web Key Set for JWT validation | | `GET /cognito-idp/oauth2/authorize` | OIDC authorization-code start endpoint | | `GET /cognito-idp/oauth2/idpresponse` | OIDC provider callback endpoint | -| `POST /cognito-idp/oauth2/token` | OAuth authorization-code and client-credentials token endpoint | +| `GET, POST /cognito-idp/login` | Hosted login page for the pool's own users | +| `GET /cognito-idp/logout` | Ends the hosted login session | +| `POST /cognito-idp/oauth2/token` | OAuth authorization-code, refresh-token and client-credentials token endpoint | The OAuth endpoints support browser-style OIDC authorization-code sign-in as well as the emulator-friendly client-credentials flow: -- `GET /cognito-idp/oauth2/authorize` validates the app client and callback, then redirects - to the configured provider with an opaque state and nonce. +- `GET /cognito-idp/oauth2/authorize` validates the app client and callback. With + `identity_provider` naming an OIDC provider it redirects there with an opaque state and nonce. + 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 + 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 + unconfirmed users get the same answer until the password is right. Users with a temporary + password, MFA or a pending reset are told so; those steps are not served on the page yet. +- `GET /cognito-idp/logout?client_id=...&logout_uri=...` ends the session and redirects to + `logout_uri`, which must be one of the client's `LogoutURLs`. With `redirect_uri` and + `response_type=code` instead, it ends the session and shows the login page again. +- PKCE: send `code_challenge` with `code_challenge_method=S256` to authorize, and the matching + `code_verifier` when redeeming the code. `nonce` is copied into the ID token. +- Codes from the hosted login and from federated sign-in fire the Pre Token Generation trigger + with `triggerSource=TokenGeneration_HostedAuth`, and the hosted login fires Pre and Post + Authentication. - `GET /cognito-idp/oauth2/idpresponse` consumes the provider state, exchanges the provider code and redirects to the registered callback with a one-time Cognito authorization code. -- `POST /cognito-idp/oauth2/token` redeems that authorization code once, or issues a machine - token for `grant_type=client_credentials`. +- `POST /cognito-idp/oauth2/token` redeems that authorization code once, exchanges a + `refresh_token` for new access and ID tokens, or issues a machine token for + `grant_type=client_credentials`. `POST /cognito-idp/oauth2/token` is intentionally emulator-friendly rather than full Cognito parity: diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java index 1888608..7f84616 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java @@ -1363,6 +1363,62 @@ final class CognitoAuthFlowHandler { return service.generateAuthResult(user, pool, client, override); } + /** + * Checks a password typed into the hosted login page. Same rules and triggers as + * USER_PASSWORD_AUTH, minus SECRET_HASH: the page is Cognito's own, not the app client. + * Throws {@code NewPasswordRequiredException} when the user still has a temporary password. + */ + CognitoUser authenticateHostedLogin(UserPool pool, UserPoolClient client, String username, String password) { + CognitoUser user; + try { + user = service.adminGetUser(pool.getId(), username); + } catch (AwsException ae) { + if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae; + user = tryUserMigration(pool, client, username, password, null, Map.of(), + "UserMigration_Authentication"); + if (user == null) throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400); + } + + firePreAuthentication(pool, client, user, null, Map.of(), false); + + if (!user.isEnabled()) throw new AwsException("NotAuthorizedException", "User is disabled", 400); + if ("RESET_REQUIRED".equals(user.getUserStatus())) { + throw new AwsException("PasswordResetRequiredException", "Password reset required", 400); + } + if (user.getPasswordHash() == null || !user.getPasswordHash().equals(service.hashPassword(password))) { + throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400); + } + if ("UNCONFIRMED".equals(user.getUserStatus())) { + throw new AwsException("UserNotConfirmedException", "User is not confirmed", 400); + } + if (user.isTemporaryPassword() || "FORCE_CHANGE_PASSWORD".equals(user.getUserStatus())) { + throw new AwsException("NewPasswordRequiredException", "Password change required", 400); + } + firePostAuthentication(pool, client, user, Map.of(), false); + return user; + } + + /** Tokens for an authorization code from the hosted login or a federated sign-in. */ + Map issueHostedTokens(UserPool pool, UserPoolClient client, CognitoUser user, String nonce) { + CognitoService.ClaimsOverride override = withIdClaim( + firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_HostedAuth"), "nonce", nonce); + return service.generateAuthResult(user, pool, client, override); + } + + private static CognitoService.ClaimsOverride withIdClaim(CognitoService.ClaimsOverride override, + String name, String value) { + if (value == null || value.isBlank()) return override; + CognitoService.ClaimsOverride base = override != null ? override + : new CognitoService.ClaimsOverride(null, null, null, null, null, null, null, null, null); + Map idClaims = new HashMap<>(); + if (base.idClaimsToAddOrOverride() != null) idClaims.putAll(base.idClaimsToAddOrOverride()); + idClaims.put(name, value); + return new CognitoService.ClaimsOverride(idClaims, base.idClaimsToSuppress(), + base.accessClaimsToAddOrOverride(), base.accessClaimsToSuppress(), + base.scopesToAdd(), base.scopesToSuppress(), + base.groupsToOverride(), base.iamRolesToOverride(), base.preferredRole()); + } + CognitoService.ClaimsOverride preTokenGenerationForRefresh(UserPool pool, UserPoolClient client, CognitoUser user) { return firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_RefreshTokens"); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilter.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilter.java index a81f560..6684520 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilter.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilter.java @@ -14,12 +14,13 @@ import org.jboss.logging.Logger; import java.net.URI; import java.util.Optional; +import java.util.Set; /** * Routes Cognito custom-domain requests by Host. On AWS a custom domain serves - * {@code https:///oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize}, and - * {@code /oauth2/idpresponse}; Floci maps those onto - * the {@code /cognito-idp/oauth2/...} handlers and pins the pool and the account that own the + * {@code https:///oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize}, + * {@code /oauth2/idpresponse} and the hosted {@code /login} and {@code /logout} pages; Floci maps + * those onto the {@code /cognito-idp/...} handlers and pins the pool and the account that own the * domain, since the request itself carries no AWS credential. */ @Provider @@ -37,6 +38,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter { private static final Logger LOG = Logger.getLogger(CognitoCustomDomainFilter.class); private static final String OAUTH_PREFIX = "/oauth2/"; private static final String TARGET_PREFIX = "/cognito-idp/oauth2/"; + private static final Set HOSTED_PAGES = Set.of("/login", "/logout"); + private static final String HOSTED_TARGET_PREFIX = "/cognito-idp/"; private final CognitoService cognitoService; @@ -49,7 +52,11 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter { public void filter(ContainerRequestContext requestContext) { URI originalUri = requestContext.getUriInfo().getRequestUri(); String path = originalUri.getRawPath(); - if (path == null || !path.startsWith(OAUTH_PREFIX)) { + if (path == null) { + return; + } + boolean hostedPage = HOSTED_PAGES.contains(path); + if (!path.startsWith(OAUTH_PREFIX) && !hostedPage) { return; } String host = RequestHost.of(requestContext); @@ -62,7 +69,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter { } URI newUri = UriBuilder.fromUri(originalUri) - .replacePath(TARGET_PREFIX + path.substring(OAUTH_PREFIX.length())) + .replacePath(hostedPage ? HOSTED_TARGET_PREFIX + path.substring(1) + : TARGET_PREFIX + path.substring(OAUTH_PREFIX.length())) .build(); LOG.debugv("Cognito custom domain routing: {0}{1} -> {2}", host, path, newUri.getPath()); requestContext.setProperty(POOL_PROPERTY, domain.get().getUserPoolId()); diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationService.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationService.java index 06470cc..e56e227 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationService.java @@ -47,6 +47,12 @@ public class CognitoFederationService { public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List scopes, String nonce, String providerName, String relyingPartyState) { + return beginAuthorization(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, null); + } + + public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List scopes, + String nonce, String providerName, String relyingPartyState, + String codeChallenge) { IdentityProvider provider = cognitoService.describeIdentityProvider(userPoolId, providerName); requireOidcProvider(provider); String authorizeEndpoint = requiredProviderDetail(provider, "authorize_url", "authorize endpoint"); @@ -54,7 +60,8 @@ public class CognitoFederationService { validateAuthorizeEndpoint(authorizeEndpoint, provider.getProviderName()); Instant expiresAt = clock.instant().plus(TRANSACTION_LIFETIME); CognitoAuthorizationTransaction transaction = new CognitoAuthorizationTransaction( - userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt); + userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt, + codeChallenge); String state = stateStore.putTransaction(transaction); Map parameters = new LinkedHashMap<>(); @@ -89,7 +96,8 @@ public class CognitoFederationService { subject, issuer, mapAttributes(provider, claims)); CognitoAuthorizationCode authorizationCode = new CognitoAuthorizationCode( transaction.userPoolId(), transaction.clientId(), user.getUsername(), transaction.redirectUri(), - transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME)); + transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME), transaction.nonce(), + transaction.codeChallenge()); return stateStore.putAuthorizationCode(authorizationCode); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationStateStore.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationStateStore.java index ee35192..93ea0f1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationStateStore.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoFederationStateStore.java @@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cognito; import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode; import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction; +import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession; import jakarta.enterprise.context.ApplicationScoped; import java.security.SecureRandom; @@ -20,6 +21,7 @@ public class CognitoFederationStateStore { private final ConcurrentHashMap transactions = new ConcurrentHashMap<>(); private final ConcurrentHashMap authorizationCodes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap hostedSessions = new ConcurrentHashMap<>(); private final Clock clock; private final SecureRandom secureRandom = new SecureRandom(); @@ -55,6 +57,31 @@ public class CognitoFederationStateStore { return Optional.of(authorizationCode); } + public String putHostedSession(CognitoHostedSession session) { + return put(hostedSessions, session); + } + + public Optional findHostedSession(String id) { + if (id == null) { + return Optional.empty(); + } + CognitoHostedSession session = hostedSessions.get(id); + if (session == null) { + return Optional.empty(); + } + if (isExpired(session.expiresAt())) { + hostedSessions.remove(id, session); + return Optional.empty(); + } + return Optional.of(session); + } + + public void removeHostedSession(String id) { + if (id != null) { + hostedSessions.remove(id); + } + } + private String put(ConcurrentHashMap store, T value) { String key; do { 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 new file mode 100644 index 0000000..9e6ca36 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginController.java @@ -0,0 +1,464 @@ +package io.github.hectorvent.floci.services.cognito; + +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode; +import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession; +import io.github.hectorvent.floci.services.cognito.model.CognitoUser; +import io.github.hectorvent.floci.services.cognito.model.UserPool; +import io.github.hectorvent.floci.services.cognito.model.UserPoolClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Cognito's hosted (managed) login pages: {@code /login} and {@code /logout}, reached from + * {@code /oauth2/authorize} when no external identity provider is named. A successful sign-in + * starts a browser session and redirects to the client's callback URL with an authorization + * code, which {@code /oauth2/token} redeems. + */ +@ApplicationScoped +@Path("/") +public class CognitoHostedLoginController { + + static final String SESSION_COOKIE = "cognito"; + static final String CSRF_COOKIE = "XSRF-TOKEN"; + 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 final CognitoService cognitoService; + private final CognitoFederationStateStore stateStore; + private final Clock clock; + private final SecureRandom secureRandom = new SecureRandom(); + + @Inject + public CognitoHostedLoginController(CognitoService cognitoService, CognitoFederationStateStore stateStore, + Clock clock) { + this.cognitoService = cognitoService; + this.stateStore = stateStore; + this.clock = clock; + } + + /** The parameters of an authorize request, carried through the login form unchanged. */ + record AuthorizeRequest(String clientId, String redirectUri, String scope, String state, String nonce, + String codeChallenge, String codeChallengeMethod) { + + Map asQuery() { + Map query = new LinkedHashMap<>(); + query.put("client_id", clientId); + query.put("redirect_uri", redirectUri); + query.put("response_type", "code"); + putIfPresent(query, "scope", scope); + putIfPresent(query, "state", state); + putIfPresent(query, "nonce", nonce); + putIfPresent(query, "code_challenge", codeChallenge); + putIfPresent(query, "code_challenge_method", codeChallengeMethod); + return query; + } + } + + /** + * 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. + */ + 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 (!"login".equals(prompt)) { + Optional signedIn = sessionUser(client, sessionId); + if (signedIn.isPresent()) { + return redirectWithCode(client, request, signedIn.get(), null); + } + } + return Response.status(Response.Status.FOUND) + .location(URI.create(pagePath(requestContext, "login") + "?" + query(request.asQuery()))) + .build(); + } + + @GET + @Path("/cognito-idp/login") + public Response loginPage(@Context ContainerRequestContext requestContext, + @QueryParam("client_id") String clientId, + @QueryParam("redirect_uri") String redirectUri, + @QueryParam("response_type") String responseType, + @QueryParam("scope") String scope, + @QueryParam("state") String state, + @QueryParam("nonce") String nonce, + @QueryParam("code_challenge") String codeChallenge, + @QueryParam("code_challenge_method") String codeChallengeMethod) { + AuthorizeRequest request = new AuthorizeRequest(trimToNull(clientId), trimToNull(redirectUri), + trimToNull(scope), state, trimToNull(nonce), trimToNull(codeChallenge), trimToNull(codeChallengeMethod)); + Object validated = validate(requestContext, request, responseType); + if (validated instanceof Response error) { + return error; + } + String csrf = randomToken(); + return loginForm((UserPoolClient) validated, request, csrf, null, null, 200) + .cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1)) + .build(); + } + + @POST + @Path("/cognito-idp/login") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response submitLogin(@Context ContainerRequestContext requestContext, + @CookieParam(CSRF_COOKIE) String csrfCookie, + MultivaluedMap form) { + AuthorizeRequest request = new AuthorizeRequest(trimToNull(form.getFirst("client_id")), + trimToNull(form.getFirst("redirect_uri")), trimToNull(form.getFirst("scope")), + form.getFirst("state"), trimToNull(form.getFirst("nonce")), + trimToNull(form.getFirst("code_challenge")), trimToNull(form.getFirst("code_challenge_method"))); + Object validated = validate(requestContext, request, form.getFirst("response_type")); + if (validated instanceof Response error) { + return error; + } + UserPoolClient client = (UserPoolClient) validated; + 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."); + } + + 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(); + } + + CognitoUser user; + try { + user = cognitoService.authenticateHostedLogin(client, username, password); + } catch (AwsException e) { + return loginForm(client, request, csrf, username, describe(e), 400).build(); + } + + String sessionId = stateStore.putHostedSession(new CognitoHostedSession( + client.getUserPoolId(), user.getUsername(), clock.instant().plus(SESSION_LIFETIME))); + return redirectWithCode(client, request, user, + cookie(requestContext, SESSION_COOKIE, sessionId, (int) SESSION_LIFETIME.toSeconds())); + } + + @GET + @Path("/cognito-idp/logout") + public Response logout(@Context ContainerRequestContext requestContext, + @CookieParam(SESSION_COOKIE) String sessionId, + @QueryParam("client_id") String clientId, + @QueryParam("logout_uri") String logoutUri, + @QueryParam("redirect_uri") String redirectUri, + @QueryParam("response_type") String responseType, + @QueryParam("scope") String scope, + @QueryParam("state") String state) { + UserPoolClient client = findClient(requestContext, trimToNull(clientId)); + if (client == null) { + return errorPage("The app client was not found."); + } + + String location; + if (trimToNull(logoutUri) != null) { + if (!client.getLogoutURLs().contains(logoutUri.trim())) { + return errorPage("This sign-out URL is not registered for the app client."); + } + location = logoutUri.trim(); + } else { + AuthorizeRequest request = new AuthorizeRequest(client.getClientId(), trimToNull(redirectUri), + trimToNull(scope), state, null, null, null); + Object validated = validate(requestContext, request, responseType); + if (validated instanceof Response error) { + return error; + } + location = pagePath(requestContext, "login") + "?" + query(request.asQuery()); + } + + stateStore.removeHostedSession(sessionId); + return Response.status(Response.Status.FOUND) + .location(URI.create(location)) + .cookie(cookie(requestContext, SESSION_COOKIE, "", 0)) + .build(); + } + + private Optional sessionUser(UserPoolClient client, String sessionId) { + Optional session = stateStore.findHostedSession(sessionId) + .filter(s -> s.userPoolId().equals(client.getUserPoolId())); + if (session.isEmpty()) { + return Optional.empty(); + } + try { + CognitoUser user = cognitoService.adminGetUser(client.getUserPoolId(), session.get().username()); + return user.isEnabled() ? Optional.of(user) : Optional.empty(); + } catch (AwsException e) { + return Optional.empty(); + } + } + + private Response redirectWithCode(UserPoolClient client, AuthorizeRequest request, CognitoUser user, + NewCookie sessionCookie) { + String code = stateStore.putAuthorizationCode(new CognitoAuthorizationCode( + client.getUserPoolId(), client.getClientId(), user.getUsername(), request.redirectUri(), + splitScopes(request.scope()), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME), + request.nonce(), request.codeChallenge())); + Map parameters = new LinkedHashMap<>(); + parameters.put("code", code); + putIfPresent(parameters, "state", request.state()); + String redirectUri = request.redirectUri(); + Response.ResponseBuilder response = Response.status(Response.Status.FOUND) + .location(URI.create(redirectUri + (redirectUri.contains("?") ? "&" : "?") + query(parameters))); + if (sessionCookie != null) { + response.cookie(sessionCookie); + } + return response.build(); + } + + /** Returns the client when the request is one Cognito would accept, otherwise an error page. */ + private Object validate(ContainerRequestContext requestContext, AuthorizeRequest request, String responseType) { + if (!"code".equals(trimToNull(responseType))) { + return errorPage("Only response_type=code is supported."); + } + if (request.clientId() == null || request.redirectUri() == null) { + return errorPage("client_id and redirect_uri are required."); + } + UserPoolClient client = findClient(requestContext, request.clientId()); + if (client == null) { + return errorPage("The app client was not found."); + } + if (!client.isAllowedOAuthFlowsUserPoolClient() || !client.getAllowedOAuthFlows().contains("code")) { + return errorPage("This app client is not allowed to use the authorization code flow."); + } + 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)."); + } + String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod()); + if (pkceError != null) { + return errorPage(pkceError); + } + return client; + } + + static String pkceError(String codeChallenge, String codeChallengeMethod) { + if (codeChallenge == null) { + return codeChallengeMethod == null ? null : "code_challenge_method was sent without code_challenge."; + } + if (!"S256".equals(codeChallengeMethod)) { + return "code_challenge_method must be S256."; + } + return null; + } + + private UserPoolClient findClient(ContainerRequestContext requestContext, String clientId) { + if (clientId == null) { + return null; + } + try { + UserPoolClient client = cognitoService.findClientById(clientId); + String domainPoolId = (String) requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY); + return domainPoolId == null || domainPoolId.equals(client.getUserPoolId()) ? client : null; + } catch (AwsException e) { + return null; + } + } + + private static String describe(AwsException e) { + return switch (e.getErrorCode()) { + case "NotAuthorizedException" -> "User is disabled".equals(e.getMessage()) + ? "This account is disabled." : "Incorrect user name or password."; + case "UserNotConfirmedException" -> "This account is not confirmed yet. Confirm it from the app first."; + case "PasswordResetRequiredException" -> "This account needs a password reset before it can sign in."; + case "NewPasswordRequiredException" -> "This account still has a temporary password. " + + "Set a permanent one before signing in here."; + case "UserLambdaValidationException" -> e.getMessage(); + default -> "Sign-in failed: " + e.getMessage(); + }; + } + + private Response.ResponseBuilder loginForm(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(); + for (Map.Entry field : request.asQuery().entrySet()) { + hidden.append(""); + } + hidden.append(""); + String appName = client.getClientName() == null || client.getClientName().isBlank() + ? pool.getName() : client.getClientName(); + String body = """ +

Sign in

+

to continue to %s

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

" + escape(error) + "

", + hidden, email ? "Email" : "User name", email ? "email" : "text", escape(username == null ? "" : username)); + return Response.status(status).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8")) + .entity(page("Sign in", body)) + .header("Cache-Control", "no-store") + .header("X-Frame-Options", "DENY") + .header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' " + + cspSource(request.redirectUri()) + "; frame-ancestors 'none'"); + } + + private static String cspSource(String redirectUri) { + URI uri = URI.create(redirectUri); + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + + private Response errorPage(String message) { + return Response.status(400).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8")) + .entity(page("Sign-in error", "

Something went wrong

" + + escape(message) + "

")) + .header("Cache-Control", "no-store") + .header("X-Frame-Options", "DENY") + .header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'") + .build(); + } + + private static String page(String title, String body) { + return """ + + + + + + %s + + +
%s
Cognito-compatible sign-in · local emulator
+ + """.formatted(escape(title), body); + } + + private NewCookie cookie(ContainerRequestContext requestContext, String name, String value, int maxAge) { + return new NewCookie.Builder(name).value(value).path("/").maxAge(maxAge) + .httpOnly(true).secure(isHttps(requestContext)).sameSite(NewCookie.SameSite.LAX).build(); + } + + private static boolean isHttps(ContainerRequestContext requestContext) { + String forwarded = requestContext.getHeaderString("X-Forwarded-Proto"); + if (forwarded != null) { + return "https".equalsIgnoreCase(forwarded.split(",")[0].trim()); + } + return "https".equalsIgnoreCase(requestContext.getUriInfo().getRequestUri().getScheme()); + } + + /** 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; + return (customDomain ? "/" : "/cognito-idp/") + page; + } + + private String randomToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static boolean constantTimeEquals(String a, String b) { + return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + + static String escape(String value) { + StringBuilder escaped = new StringBuilder(value.length()); + for (char c : value.toCharArray()) { + switch (c) { + case '&' -> escaped.append("&"); + case '<' -> escaped.append("<"); + case '>' -> escaped.append(">"); + case '"' -> escaped.append("""); + case '\'' -> escaped.append("'"); + default -> escaped.append(c); + } + } + return escaped.toString(); + } + + private static String query(Map parameters) { + List pairs = new ArrayList<>(); + for (Map.Entry parameter : parameters.entrySet()) { + pairs.add(encode(parameter.getKey()) + "=" + encode(parameter.getValue())); + } + return String.join("&", pairs); + } + + /** Query-string encoding with spaces as %20, which every URL parser reads back as a space. */ + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static void putIfPresent(Map map, String key, String value) { + if (value != null) { + map.put(key, value); + } + } + + private static List splitScopes(String scope) { + if (scope == null) { + return List.of(); + } + List scopes = new ArrayList<>(); + for (String value : scope.trim().split("\\s+")) { + if (!value.isBlank()) { + scopes.add(value); + } + } + return scopes; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java index f38f1cf..b71007b 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java @@ -6,11 +6,11 @@ import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode; import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction; import io.github.hectorvent.floci.services.cognito.model.CognitoUser; -import io.github.hectorvent.floci.services.cognito.model.UserPool; import io.github.hectorvent.floci.services.cognito.model.UserPoolClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; @@ -46,17 +46,37 @@ public class CognitoOAuthController { private final ObjectMapper objectMapper; private final CognitoFederationService federationService; private final CognitoFederationStateStore stateStore; + private final CognitoHostedLoginController hostedLogin; @Inject public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper, CognitoFederationService federationService, - CognitoFederationStateStore stateStore) { + CognitoFederationStateStore stateStore, + CognitoHostedLoginController hostedLogin) { this.cognitoService = cognitoService; this.objectMapper = objectMapper; this.federationService = federationService; this.stateStore = stateStore; + this.hostedLogin = hostedLogin; } + public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper, + CognitoFederationService federationService, + CognitoFederationStateStore stateStore) { + this(cognitoService, objectMapper, federationService, stateStore, null); + } + + public Response authorize(ContainerRequestContext requestContext, String clientId, String redirectUri, + String responseType, String scope, String nonce, String providerName, + String relyingPartyState) { + return authorize(requestContext, clientId, redirectUri, responseType, scope, nonce, providerName, + relyingPartyState, null, null, null, null); + } + + /** + * Without {@code identity_provider} (or with {@code COGNITO}) the pool's own users sign in on + * the hosted login page; any other provider name starts a federated sign-in. + */ @GET @Path("/cognito-idp/oauth2/authorize") public Response authorize(@Context ContainerRequestContext requestContext, @@ -66,13 +86,22 @@ public class CognitoOAuthController { @QueryParam("scope") String scope, @QueryParam("nonce") String nonce, @QueryParam("identity_provider") String providerName, - @QueryParam("state") String relyingPartyState) { + @QueryParam("state") String relyingPartyState, + @QueryParam("code_challenge") String codeChallenge, + @QueryParam("code_challenge_method") String codeChallengeMethod, + @QueryParam("prompt") String prompt, + @CookieParam(CognitoHostedLoginController.SESSION_COOKIE) String sessionId) { if (!"code".equals(trimToNull(responseType))) { return oauthError("unsupported_response_type", "Only response_type=code is supported"); } - if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || trimToNull(providerName) == null) { + boolean hosted = trimToNull(providerName) == null || "COGNITO".equals(providerName.trim()); + if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || (hosted && hostedLogin == null)) { return oauthError("invalid_request", "client_id, redirect_uri, and identity_provider are required"); } + String pkceError = CognitoHostedLoginController.pkceError(trimToNull(codeChallenge), trimToNull(codeChallengeMethod)); + if (pkceError != null) { + return oauthError("invalid_request", pkceError); + } UserPoolClient client; try { @@ -91,9 +120,20 @@ public class CognitoOAuthController { return oauthError("invalid_request", "redirect_uri is not registered for this client"); } + if (hosted) { + return hostedLogin.authorizeWithCognito(requestContext, client, + new CognitoHostedLoginController.AuthorizeRequest(clientId.trim(), redirectUri, trimToNull(scope), + relyingPartyState, trimToNull(nonce), trimToNull(codeChallenge), + trimToNull(codeChallengeMethod)), + trimToNull(prompt), sessionId); + } + try { - String location = federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri, - splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState); + String location = trimToNull(codeChallenge) == null + ? federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri, + splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState) + : federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri, + splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState, codeChallenge.trim()); return Response.status(Response.Status.FOUND).location(URI.create(location)).build(); } catch (AwsException e) { return oauthError("invalid_request", e.getMessage()); @@ -185,8 +225,12 @@ public class CognitoOAuthController { if ("authorization_code".equals(grantType)) { return redeemAuthorizationCode(clientId, clientSecret, formParams, domainPoolId); } + if ("refresh_token".equals(grantType)) { + return refreshTokens(clientId, clientSecret, formParams, domainPoolId); + } if (!"client_credentials".equals(grantType)) { - return oauthError("unsupported_grant_type", "Only client_credentials and authorization_code are supported"); + return oauthError("unsupported_grant_type", + "Only client_credentials, authorization_code and refresh_token are supported"); } String scope = trimToNull(formParams.getFirst("scope")); @@ -249,10 +293,12 @@ public class CognitoOAuthController { return oauthError("invalid_grant", "Authorization code is invalid or has expired"); } CognitoAuthorizationCode consumedCode = consumed.get(); + if (!pkceVerified(consumedCode.codeChallenge(), trimToNull(formParams.getFirst("code_verifier")))) { + return oauthError("invalid_grant", "code_verifier does not match the code_challenge"); + } try { - UserPool pool = cognitoService.describeUserPool(consumedCode.userPoolId()); CognitoUser user = cognitoService.adminGetUser(consumedCode.userPoolId(), consumedCode.userId()); - Map authentication = cognitoService.generateAuthResult(user, pool, client, null); + Map authentication = cognitoService.issueHostedTokens(client, user, consumedCode.nonce()); ObjectNode body = objectMapper.createObjectNode(); body.put("access_token", (String) authentication.get("AccessToken")); body.put("id_token", (String) authentication.get("IdToken")); @@ -267,6 +313,67 @@ public class CognitoOAuthController { } } + private Response refreshTokens(String clientId, String clientSecret, MultivaluedMap formParams, + String domainPoolId) { + String refreshToken = trimToNull(formParams.getFirst("refresh_token")); + if (refreshToken == null) { + return oauthError("invalid_request", "refresh_token is required"); + } + UserPoolClient client; + try { + client = cognitoService.findClientById(clientId); + } catch (AwsException e) { + return oauthError("invalid_client", "Client not found"); + } + if (domainPoolId != null && !domainPoolId.equals(client.getUserPoolId())) { + return oauthError("invalid_client", "Client not found"); + } + if (client.getClientSecret() != null && !client.getClientSecret().isBlank() + && !secretsEqual(client.getClientSecret(), clientSecret)) { + return oauthError("invalid_client", "Client secret is invalid"); + } + try { + Map result = cognitoService.initiateAuth(clientId, "REFRESH_TOKEN_AUTH", + Map.of("REFRESH_TOKEN", refreshToken)); + @SuppressWarnings("unchecked") + Map authentication = (Map) result.get("AuthenticationResult"); + ObjectNode body = objectMapper.createObjectNode(); + body.put("access_token", (String) authentication.get("AccessToken")); + body.put("id_token", (String) authentication.get("IdToken")); + body.put("expires_in", ((Number) authentication.get("ExpiresIn")).longValue()); + body.put("token_type", (String) authentication.get("TokenType")); + return Response.ok(body).type(MediaType.APPLICATION_JSON) + .header("Cache-Control", "no-store").header("Pragma", "no-cache").build(); + } catch (AwsException e) { + if ("NotAuthorizedException".equals(e.getErrorCode())) { + return oauthError("invalid_grant", e.getMessage()); + } + if ("InvalidParameterException".equals(e.getErrorCode())) { + return oauthError("unauthorized_client", e.getMessage()); + } + LOG.error("Failed to refresh Cognito tokens", e); + return oauthError("invalid_request", e.getMessage()); + } + } + + /** RFC 7636 S256: the challenge is BASE64URL(SHA-256(verifier)). No challenge, nothing to check. */ + static boolean pkceVerified(String codeChallenge, String codeVerifier) { + if (codeChallenge == null) { + return true; + } + if (codeVerifier == null || codeVerifier.length() < 43 || codeVerifier.length() > 128) { + return false; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + codeChallenge.getBytes(StandardCharsets.US_ASCII)); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private Response redirect(String redirectUri, Map parameters) { StringBuilder location = new StringBuilder(redirectUri); location.append(redirectUri.contains("?") ? '&' : '?'); diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java index 0c3766b..2003179 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java @@ -2695,6 +2695,14 @@ public class CognitoService implements ResourceProvider { // ──────────────────────────── Auth ──────────────────────────── + public CognitoUser authenticateHostedLogin(UserPoolClient client, String username, String password) { + return authFlowHandler.authenticateHostedLogin(describeUserPool(client.getUserPoolId()), client, username, password); + } + + public Map issueHostedTokens(UserPoolClient client, CognitoUser user, String nonce) { + return authFlowHandler.issueHostedTokens(describeUserPool(client.getUserPoolId()), client, user, nonce); + } + public Map initiateAuth(String clientId, String authFlow, Map authParameters) { return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of()); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoWellKnownController.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoWellKnownController.java index d3bae7b..2867909 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoWellKnownController.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoWellKnownController.java @@ -66,7 +66,7 @@ public class CognitoWellKnownController { String authorizationEndpoint = tokenEndpoint.replace("/token", "/authorize"); String body = """ - {"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]} + {"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]} """.formatted(issuer, jwksUri, authorizationEndpoint, tokenEndpoint, userInfoEndpoint).strip(); return Response.ok(body).build(); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationCode.java b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationCode.java index f71fc8a..540c951 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationCode.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationCode.java @@ -3,15 +3,27 @@ package io.github.hectorvent.floci.services.cognito.model; import java.time.Instant; import java.util.List; +/** + * A one-time code handed to the client's redirect URI. {@code codeChallenge} is the PKCE S256 + * challenge from the authorize request, and {@code nonce} is copied into the ID token; both are + * null when the client did not send them. + */ public record CognitoAuthorizationCode( String userPoolId, String clientId, String userId, String redirectUri, List scopes, - Instant expiresAt) { + Instant expiresAt, + String nonce, + String codeChallenge) { public CognitoAuthorizationCode { scopes = List.copyOf(scopes); } + + public CognitoAuthorizationCode(String userPoolId, String clientId, String userId, String redirectUri, + List scopes, Instant expiresAt) { + this(userPoolId, clientId, userId, redirectUri, scopes, expiresAt, null, null); + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationTransaction.java b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationTransaction.java index 1c2482a..0c6d8ea 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationTransaction.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoAuthorizationTransaction.java @@ -11,9 +11,16 @@ public record CognitoAuthorizationTransaction( String nonce, String providerName, String relyingPartyState, - Instant expiresAt) { + Instant expiresAt, + String codeChallenge) { public CognitoAuthorizationTransaction { scopes = List.copyOf(scopes); } + + public CognitoAuthorizationTransaction(String userPoolId, String clientId, String redirectUri, List scopes, + String nonce, String providerName, String relyingPartyState, + Instant expiresAt) { + this(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt, null); + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoHostedSession.java b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoHostedSession.java new file mode 100644 index 0000000..dc53067 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/model/CognitoHostedSession.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.cognito.model; + +import java.time.Instant; + +/** A signed-in browser on the hosted login pages, so the next authorize request skips the form. */ +public record CognitoHostedSession(String userPoolId, String username, Instant expiresAt) { +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilterTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilterTest.java index d5a59d7..5fa847e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilterTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoCustomDomainFilterTest.java @@ -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() { 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 new file mode 100644 index 0000000..4abe050 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoHostedLoginIntegrationTest.java @@ -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 hosted-web")) + .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("").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; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthControllerTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthControllerTest.java index 31dcbd5..bda790a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthControllerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthControllerTest.java @@ -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; diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthTokenIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthTokenIntegrationTest.java index 5369fcf..55bfad9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthTokenIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthTokenIntegrationTest.java @@ -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") -- 2.52.0