Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4bbb6fab4 | |||
| e7fb8bb8d6 |
+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,
|
OIDC provider, exchanges the returned code, provisions or reconciles the federated user,
|
||||||
and issues a Cognito authorization code for the registered callback.
|
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
|
Only `ProviderType=OIDC` is supported by this flow. Floci does not implement social providers
|
||||||
UI and does not implement social providers such as Google, Facebook, Login with Amazon or
|
such as Google, Facebook, Login with Amazon or SignInWithApple. The pool's own users sign in on
|
||||||
SignInWithApple.
|
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
|
Two deliberate divergences from AWS, both consequences of not calling out to a third
|
||||||
party:
|
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
|
problem rather than `ResourceNotFoundException`, and an update violating both reports them
|
||||||
in one message with the asset list first.
|
in one message with the asset list first.
|
||||||
|
|
||||||
Branding is presentation for the hosted UI, which Floci does not serve, so it is stored
|
Branding is presentation for the hosted UI. Floci's hosted login page does not apply it, so it
|
||||||
and returned rather than rendered. Two divergences follow from that:
|
is stored and returned rather than rendered. Two divergences follow from that:
|
||||||
|
|
||||||
- **`Settings` is stored opaquely.** AWS validates it against a deep schema, rejecting
|
- **`Settings` is stored opaquely.** AWS validates it against a deep schema, rejecting
|
||||||
unknown properties with `Invalid settings provided. Validation errors: [{property:
|
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 /{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/authorize` | OIDC authorization-code start endpoint |
|
||||||
| `GET /cognito-idp/oauth2/idpresponse` | OIDC provider callback 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
|
The OAuth endpoints support browser-style OIDC authorization-code sign-in as well as the
|
||||||
emulator-friendly client-credentials flow:
|
emulator-friendly client-credentials flow:
|
||||||
|
|
||||||
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback, then redirects
|
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback. With
|
||||||
to the configured provider with an opaque state and nonce.
|
`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
|
- `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.
|
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
|
- `POST /cognito-idp/oauth2/token` redeems that authorization code once, exchanges a
|
||||||
token for `grant_type=client_credentials`.
|
`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:
|
`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);
|
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) {
|
CognitoService.ClaimsOverride preTokenGenerationForRefresh(UserPool pool, UserPoolClient client, CognitoUser user) {
|
||||||
return firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_RefreshTokens");
|
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.net.URI;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes Cognito custom-domain requests by Host. On AWS a custom domain serves
|
* 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 https://<domain>/oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize},
|
||||||
* {@code /oauth2/idpresponse}; Floci maps those onto
|
* {@code /oauth2/idpresponse} and the hosted {@code /login} and {@code /logout} pages; Floci maps
|
||||||
* the {@code /cognito-idp/oauth2/...} handlers and pins the pool and the account that own the
|
* 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.
|
* domain, since the request itself carries no AWS credential.
|
||||||
*/
|
*/
|
||||||
@Provider
|
@Provider
|
||||||
@@ -37,6 +38,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
|||||||
private static final Logger LOG = Logger.getLogger(CognitoCustomDomainFilter.class);
|
private static final Logger LOG = Logger.getLogger(CognitoCustomDomainFilter.class);
|
||||||
private static final String OAUTH_PREFIX = "/oauth2/";
|
private static final String OAUTH_PREFIX = "/oauth2/";
|
||||||
private static final String TARGET_PREFIX = "/cognito-idp/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;
|
private final CognitoService cognitoService;
|
||||||
|
|
||||||
@@ -49,7 +52,11 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
|||||||
public void filter(ContainerRequestContext requestContext) {
|
public void filter(ContainerRequestContext requestContext) {
|
||||||
URI originalUri = requestContext.getUriInfo().getRequestUri();
|
URI originalUri = requestContext.getUriInfo().getRequestUri();
|
||||||
String path = originalUri.getRawPath();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
String host = RequestHost.of(requestContext);
|
String host = RequestHost.of(requestContext);
|
||||||
@@ -62,7 +69,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
URI newUri = UriBuilder.fromUri(originalUri)
|
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();
|
.build();
|
||||||
LOG.debugv("Cognito custom domain routing: {0}{1} -> {2}", host, path, newUri.getPath());
|
LOG.debugv("Cognito custom domain routing: {0}{1} -> {2}", host, path, newUri.getPath());
|
||||||
requestContext.setProperty(POOL_PROPERTY, domain.get().getUserPoolId());
|
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,
|
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> scopes,
|
||||||
String nonce, String providerName, String relyingPartyState) {
|
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);
|
IdentityProvider provider = cognitoService.describeIdentityProvider(userPoolId, providerName);
|
||||||
requireOidcProvider(provider);
|
requireOidcProvider(provider);
|
||||||
String authorizeEndpoint = requiredProviderDetail(provider, "authorize_url", "authorize endpoint");
|
String authorizeEndpoint = requiredProviderDetail(provider, "authorize_url", "authorize endpoint");
|
||||||
@@ -54,7 +60,8 @@ public class CognitoFederationService {
|
|||||||
validateAuthorizeEndpoint(authorizeEndpoint, provider.getProviderName());
|
validateAuthorizeEndpoint(authorizeEndpoint, provider.getProviderName());
|
||||||
Instant expiresAt = clock.instant().plus(TRANSACTION_LIFETIME);
|
Instant expiresAt = clock.instant().plus(TRANSACTION_LIFETIME);
|
||||||
CognitoAuthorizationTransaction transaction = new CognitoAuthorizationTransaction(
|
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);
|
String state = stateStore.putTransaction(transaction);
|
||||||
|
|
||||||
Map<String, String> parameters = new LinkedHashMap<>();
|
Map<String, String> parameters = new LinkedHashMap<>();
|
||||||
@@ -89,7 +96,8 @@ public class CognitoFederationService {
|
|||||||
subject, issuer, mapAttributes(provider, claims));
|
subject, issuer, mapAttributes(provider, claims));
|
||||||
CognitoAuthorizationCode authorizationCode = new CognitoAuthorizationCode(
|
CognitoAuthorizationCode authorizationCode = new CognitoAuthorizationCode(
|
||||||
transaction.userPoolId(), transaction.clientId(), user.getUsername(), transaction.redirectUri(),
|
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);
|
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.CognitoAuthorizationCode;
|
||||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
|
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
|
||||||
|
import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
@@ -20,6 +21,7 @@ public class CognitoFederationStateStore {
|
|||||||
|
|
||||||
private final ConcurrentHashMap<String, CognitoAuthorizationTransaction> transactions = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, CognitoAuthorizationTransaction> transactions = new ConcurrentHashMap<>();
|
||||||
private final ConcurrentHashMap<String, CognitoAuthorizationCode> authorizationCodes = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, CognitoAuthorizationCode> authorizationCodes = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, CognitoHostedSession> hostedSessions = new ConcurrentHashMap<>();
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@@ -55,6 +57,31 @@ public class CognitoFederationStateStore {
|
|||||||
return Optional.of(authorizationCode);
|
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) {
|
private <T> String put(ConcurrentHashMap<String, T> store, T value) {
|
||||||
String key;
|
String key;
|
||||||
do {
|
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.CognitoAuthorizationCode;
|
||||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
|
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.CognitoUser;
|
||||||
import io.github.hectorvent.floci.services.cognito.model.UserPool;
|
|
||||||
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
|
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.CookieParam;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.HeaderParam;
|
import jakarta.ws.rs.HeaderParam;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
@@ -46,17 +46,37 @@ public class CognitoOAuthController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final CognitoFederationService federationService;
|
private final CognitoFederationService federationService;
|
||||||
private final CognitoFederationStateStore stateStore;
|
private final CognitoFederationStateStore stateStore;
|
||||||
|
private final CognitoHostedLoginController hostedLogin;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
|
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
|
||||||
CognitoFederationService federationService,
|
CognitoFederationService federationService,
|
||||||
CognitoFederationStateStore stateStore) {
|
CognitoFederationStateStore stateStore,
|
||||||
|
CognitoHostedLoginController hostedLogin) {
|
||||||
this.cognitoService = cognitoService;
|
this.cognitoService = cognitoService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.federationService = federationService;
|
this.federationService = federationService;
|
||||||
this.stateStore = stateStore;
|
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
|
@GET
|
||||||
@Path("/cognito-idp/oauth2/authorize")
|
@Path("/cognito-idp/oauth2/authorize")
|
||||||
public Response authorize(@Context ContainerRequestContext requestContext,
|
public Response authorize(@Context ContainerRequestContext requestContext,
|
||||||
@@ -66,13 +86,22 @@ public class CognitoOAuthController {
|
|||||||
@QueryParam("scope") String scope,
|
@QueryParam("scope") String scope,
|
||||||
@QueryParam("nonce") String nonce,
|
@QueryParam("nonce") String nonce,
|
||||||
@QueryParam("identity_provider") String providerName,
|
@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))) {
|
if (!"code".equals(trimToNull(responseType))) {
|
||||||
return oauthError("unsupported_response_type", "Only response_type=code is supported");
|
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");
|
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;
|
UserPoolClient client;
|
||||||
try {
|
try {
|
||||||
@@ -91,9 +120,20 @@ public class CognitoOAuthController {
|
|||||||
return oauthError("invalid_request", "redirect_uri is not registered for this client");
|
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 {
|
try {
|
||||||
String location = federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
|
String location = trimToNull(codeChallenge) == null
|
||||||
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState);
|
? 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();
|
return Response.status(Response.Status.FOUND).location(URI.create(location)).build();
|
||||||
} catch (AwsException e) {
|
} catch (AwsException e) {
|
||||||
return oauthError("invalid_request", e.getMessage());
|
return oauthError("invalid_request", e.getMessage());
|
||||||
@@ -185,8 +225,12 @@ public class CognitoOAuthController {
|
|||||||
if ("authorization_code".equals(grantType)) {
|
if ("authorization_code".equals(grantType)) {
|
||||||
return redeemAuthorizationCode(clientId, clientSecret, formParams, domainPoolId);
|
return redeemAuthorizationCode(clientId, clientSecret, formParams, domainPoolId);
|
||||||
}
|
}
|
||||||
|
if ("refresh_token".equals(grantType)) {
|
||||||
|
return refreshTokens(clientId, clientSecret, formParams, domainPoolId);
|
||||||
|
}
|
||||||
if (!"client_credentials".equals(grantType)) {
|
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"));
|
String scope = trimToNull(formParams.getFirst("scope"));
|
||||||
|
|
||||||
@@ -249,10 +293,12 @@ public class CognitoOAuthController {
|
|||||||
return oauthError("invalid_grant", "Authorization code is invalid or has expired");
|
return oauthError("invalid_grant", "Authorization code is invalid or has expired");
|
||||||
}
|
}
|
||||||
CognitoAuthorizationCode consumedCode = consumed.get();
|
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 {
|
try {
|
||||||
UserPool pool = cognitoService.describeUserPool(consumedCode.userPoolId());
|
|
||||||
CognitoUser user = cognitoService.adminGetUser(consumedCode.userPoolId(), consumedCode.userId());
|
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();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
body.put("access_token", (String) authentication.get("AccessToken"));
|
body.put("access_token", (String) authentication.get("AccessToken"));
|
||||||
body.put("id_token", (String) authentication.get("IdToken"));
|
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) {
|
private Response redirect(String redirectUri, Map<String, String> parameters) {
|
||||||
StringBuilder location = new StringBuilder(redirectUri);
|
StringBuilder location = new StringBuilder(redirectUri);
|
||||||
location.append(redirectUri.contains("?") ? '&' : '?');
|
location.append(redirectUri.contains("?") ? '&' : '?');
|
||||||
|
|||||||
@@ -2695,6 +2695,14 @@ public class CognitoService implements ResourceProvider {
|
|||||||
|
|
||||||
// ──────────────────────────── Auth ────────────────────────────
|
// ──────────────────────────── 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) {
|
public Map<String, Object> initiateAuth(String clientId, String authFlow, Map<String, String> authParameters) {
|
||||||
return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of());
|
return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of());
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ public class CognitoWellKnownController {
|
|||||||
String authorizationEndpoint = tokenEndpoint.replace("/token", "/authorize");
|
String authorizationEndpoint = tokenEndpoint.replace("/token", "/authorize");
|
||||||
|
|
||||||
String body = """
|
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();
|
""".formatted(issuer, jwksUri, authorizationEndpoint, tokenEndpoint, userInfoEndpoint).strip();
|
||||||
return Response.ok(body).build();
|
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.time.Instant;
|
||||||
import java.util.List;
|
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(
|
public record CognitoAuthorizationCode(
|
||||||
String userPoolId,
|
String userPoolId,
|
||||||
String clientId,
|
String clientId,
|
||||||
String userId,
|
String userId,
|
||||||
String redirectUri,
|
String redirectUri,
|
||||||
List<String> scopes,
|
List<String> scopes,
|
||||||
Instant expiresAt) {
|
Instant expiresAt,
|
||||||
|
String nonce,
|
||||||
|
String codeChallenge) {
|
||||||
|
|
||||||
public CognitoAuthorizationCode {
|
public CognitoAuthorizationCode {
|
||||||
scopes = List.copyOf(scopes);
|
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 nonce,
|
||||||
String providerName,
|
String providerName,
|
||||||
String relyingPartyState,
|
String relyingPartyState,
|
||||||
Instant expiresAt) {
|
Instant expiresAt,
|
||||||
|
String codeChallenge) {
|
||||||
|
|
||||||
public CognitoAuthorizationTransaction {
|
public CognitoAuthorizationTransaction {
|
||||||
scopes = List.copyOf(scopes);
|
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"));
|
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. */
|
/** HTTP/2 carries no Host header; the authority is on the request URI. */
|
||||||
@Test
|
@Test
|
||||||
void fallsBackToTheRequestAuthorityWithoutAHostHeader() {
|
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)));
|
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
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",
|
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
"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)));
|
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
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",
|
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
||||||
return code;
|
return code;
|
||||||
|
|||||||
+1
-1
@@ -373,7 +373,7 @@ class CognitoOAuthTokenIntegrationTest {
|
|||||||
@Order(16)
|
@Order(16)
|
||||||
void unsupportedGrantTypeReturnsUnsupportedGrantType() {
|
void unsupportedGrantTypeReturnsUnsupportedGrantType() {
|
||||||
given()
|
given()
|
||||||
.formParam("grant_type", "refresh_token")
|
.formParam("grant_type", "password")
|
||||||
.formParam("client_id", clientId)
|
.formParam("client_id", clientId)
|
||||||
.when()
|
.when()
|
||||||
.post("/cognito-idp/oauth2/token")
|
.post("/cognito-idp/oauth2/token")
|
||||||
|
|||||||
Reference in New Issue
Block a user