feat(cognito): hosted login page, PKCE and refresh-token grant #2
+30
-10
@@ -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:
|
||||
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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");
|
||||
}
|
||||
|
||||
+13
-5
@@ -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://<domain>/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://<domain>/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<String> 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());
|
||||
|
||||
+10
-2
@@ -47,6 +47,12 @@ public class CognitoFederationService {
|
||||
|
||||
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> 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<String> 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<String, String> 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);
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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<String, CognitoAuthorizationTransaction> transactions = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, CognitoAuthorizationCode> authorizationCodes = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, CognitoHostedSession> 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<CognitoHostedSession> 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 <T> String put(ConcurrentHashMap<String, T> store, T value) {
|
||||
String key;
|
||||
do {
|
||||
|
||||
+464
@@ -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<String, String> asQuery() {
|
||||
Map<String, String> 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<CognitoUser> 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<String, String> 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<CognitoUser> sessionUser(UserPoolClient client, String sessionId) {
|
||||
Optional<CognitoHostedSession> 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<String, String> 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<String, String> field : request.asQuery().entrySet()) {
|
||||
hidden.append("<input type=\"hidden\" name=\"").append(escape(field.getKey()))
|
||||
.append("\" value=\"").append(escape(field.getValue())).append("\">");
|
||||
}
|
||||
hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
|
||||
String appName = client.getClientName() == null || client.getClientName().isBlank()
|
||||
? pool.getName() : client.getClientName();
|
||||
String body = """
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">to continue to <strong>%s</strong></p>
|
||||
%s
|
||||
<form method="post" action="">
|
||||
%s
|
||||
<label>%s<input name="username" type="%s" autocomplete="username" value="%s" required autofocus></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
""".formatted(escape(appName),
|
||||
error == null ? "" : "<p class=\"error\" role=\"alert\">" + escape(error) + "</p>",
|
||||
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", "<h1>Something went wrong</h1><p class=\"error\" role=\"alert\">"
|
||||
+ escape(message) + "</p>"))
|
||||
.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 """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
:root { --bg:#f4f5f7; --card:#fff; --text:#17191c; --muted:#5d6470; --border:#d9dde3; --accent:#2f5fd0; --on-accent:#fff; --error:#b42318; --error-bg:#fdeceb; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#101215; --card:#181b20; --text:#e8eaed; --muted:#9aa1ac; --border:#2c3139; --accent:#7c9ef0; --on-accent:#0b1020; --error:#f08b82; --error-bg:#3a1714; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px 16px; background:var(--bg); color:var(--text); font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
main { width:100%%; max-width:380px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:28px; }
|
||||
h1 { margin:0 0 4px; font-size:22px; }
|
||||
.muted { color:var(--muted); margin:0 0 20px; overflow-wrap:anywhere; }
|
||||
form { display:grid; gap:14px; }
|
||||
label { display:grid; gap:6px; font-weight:600; font-size:14px; }
|
||||
input { font:inherit; padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
|
||||
button { font:inherit; font-weight:600; padding:10px 12px; border:0; border-radius:6px; background:var(--accent); color:var(--on-accent); cursor:pointer; }
|
||||
.error { margin:0 0 16px; padding:10px 12px; border-radius:6px; background:var(--error-bg); color:var(--error); overflow-wrap:anywhere; }
|
||||
footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>%s<footer>Cognito-compatible sign-in · local emulator</footer></main></body>
|
||||
</html>
|
||||
""".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<String, String> parameters) {
|
||||
List<String> pairs = new ArrayList<>();
|
||||
for (Map.Entry<String, String> 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<String, String> map, String key, String value) {
|
||||
if (value != null) {
|
||||
map.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> splitScopes(String scope) {
|
||||
if (scope == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
+116
-9
@@ -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<String, Object> authentication = cognitoService.generateAuthResult(user, pool, client, null);
|
||||
Map<String, Object> 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<String, String> 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<String, Object> result = cognitoService.initiateAuth(clientId, "REFRESH_TOKEN_AUTH",
|
||||
Map.of("REFRESH_TOKEN", refreshToken));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> authentication = (Map<String, Object>) 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<String, String> parameters) {
|
||||
StringBuilder location = new StringBuilder(redirectUri);
|
||||
location.append(redirectUri.contains("?") ? '&' : '?');
|
||||
|
||||
@@ -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<String, Object> issueHostedTokens(UserPoolClient client, CognitoUser user, String nonce) {
|
||||
return authFlowHandler.issueHostedTokens(describeUserPool(client.getUserPoolId()), client, user, nonce);
|
||||
}
|
||||
|
||||
public Map<String, Object> initiateAuth(String clientId, String authFlow, Map<String, String> authParameters) {
|
||||
return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of());
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
+13
-1
@@ -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<String> 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<String> scopes, Instant expiresAt) {
|
||||
this(userPoolId, clientId, userId, redirectUri, scopes, expiresAt, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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<String> scopes,
|
||||
String nonce, String providerName, String relyingPartyState,
|
||||
Instant expiresAt) {
|
||||
this(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt, null);
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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) {
|
||||
}
|
||||
+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