| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| 9 hours ago | Jacek Kowalski | ![]() |
| README.md | ●●●●● patch | view | raw | blame | history | |
| integrationTest/suite.sh | ●●●●● patch | view | raw | blame | history | |
| src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/org/keycloak/protocol/cas/endpoints/ProxyEndpoint.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/org/keycloak/protocol/cas/endpoints/ProxyValidateEndpoint.java | ●●●●● patch | view | raw | blame | history |
README.md
@@ -54,6 +54,9 @@ In such case append `/login` to the CAS URL to get the "login URL". Similarly append `/serviceValidate` to get the "validate URL". Proxy callback URLs (`pgtUrl`) must match the client's redirect URIs and use HTTPS. Plain HTTP is accepted only if the realm's "Require SSL" setting allows it. ## Disclaimer This plugin was implemented from scratch to comply to the official CAS protocol specification, integrationTest/suite.sh
@@ -41,6 +41,14 @@ $CURL "${keycloak_cas_url}/validate?service=http://localhost&ticket=$ticket" echo echo "Testing CAS 1.0 - ticket issued for another service..." ticket=$(get_ticket) if $CURL "${keycloak_cas_url}/validate?service=http://localhost/other&ticket=$ticket"; then echo "Ticket was accepted for a different service" exit 1 fi echo # CAS 2.0 echo "Testing CAS 2.0 - XML..." ticket=$(get_ticket) @@ -58,6 +66,34 @@ $CURL "${keycloak_cas_url}/p3/serviceValidate?service=http://localhost&format=JSON&ticket=$ticket" echo # CAS 2.0 - proxy validation echo "Testing CAS 2.0 - proxyValidate..." ticket=$(get_ticket) $CURL "${keycloak_cas_url}/proxyValidate?service=http://localhost&format=JSON&ticket=$ticket" echo echo "Testing CAS 2.0 - proxyValidate without service..." ticket=$(get_ticket) if $CURL "${keycloak_cas_url}/proxyValidate?format=JSON&ticket=$ticket"; then echo "Ticket was accepted without service parameter" exit 1 fi echo echo "Testing CAS 2.0 - malformed ticket..." response=$(curl --silent --write-out '%{http_code}' --output /dev/null "${keycloak_cas_url}/serviceValidate?service=http://localhost&ticket=ST-x--zz--y") if [ "$response" != "400" ]; then echo "Malformed ticket returned HTTP ${response} instead of 400" exit 1 fi echo "Testing CAS 2.0 - proxyValidate without ticket..." response=$(curl --silent --write-out '%{http_code}' --output /dev/null "${keycloak_cas_url}/proxyValidate?service=http://localhost") if [ "$response" != "400" ]; then echo "Missing ticket returned HTTP ${response} instead of 400" exit 1 fi # SAML 1.1 echo "Testing SAML 1.1..." ticket=$(get_ticket SAML) src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java
@@ -18,6 +18,7 @@ import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.util.DefaultClientSessionContext; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HexFormat; @@ -27,19 +28,30 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.http.client.HttpClient; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.utils.URIBuilder; import org.apache.http.client.methods.HttpGet; import org.apache.http.HttpResponse; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; import org.keycloak.connections.httpclient.HttpClientProvider; public abstract class AbstractValidateEndpoint { protected final Logger logger = Logger.getLogger(getClass()); private static final Pattern DOT = Pattern.compile("\\."); private static final Pattern SEPERATION_PATTERN = Pattern.compile("--"); private static final int PGT_CALLBACK_TIMEOUT_MS = 5000; private static final RequestConfig PGT_CALLBACK_REQUEST_CONFIG = RequestConfig.custom() .setConnectTimeout(PGT_CALLBACK_TIMEOUT_MS) .setConnectionRequestTimeout(PGT_CALLBACK_TIMEOUT_MS) .setSocketTimeout(PGT_CALLBACK_TIMEOUT_MS) .setRedirectsEnabled(false) .build(); protected KeycloakSession session; protected RealmModel realm; protected EventBuilder event; protected ClientModel client; protected String service; protected AuthenticatedClientSessionModel clientSession; protected String pgtIou; @@ -69,10 +81,7 @@ event.detail(Details.REDIRECT_URI, service); client = realm.getClientsStream() .filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol())) .filter(c -> RedirectUtils.verifyRedirectUri(session, service, c) != null) .findFirst().orElse(null); client = findClient(service); if (client == null) { event.error(Errors.CLIENT_NOT_FOUND); throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client not found", Response.Status.BAD_REQUEST); @@ -83,11 +92,20 @@ throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client disabled", Response.Status.BAD_REQUEST); } this.service = RedirectUtils.verifyRedirectUri(session, service, client); event.client(client.getClientId()); session.getContext().setClient(client); } protected ClientModel findClient(String service) { return realm.getClientsStream() .filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol())) .filter(c -> RedirectUtils.verifyRedirectUri(session, service, c) != null) .findFirst().orElse(null); } protected void checkTicket(String ticket, String prefix, boolean requireReauth) { if (ticket == null) { event.error(Errors.INVALID_CODE); @@ -162,7 +180,11 @@ throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST); } clientSession.setNote(CASLoginProtocol.SESSION_TICKET, ticket); // Service and proxy tickets are valid only for the service they were issued for if (!isReusable && (service == null || !service.equals(codeData.getRedirectUriParam()))) { event.error(Errors.INVALID_REDIRECT_URI); throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Ticket was not issued for this service", Response.Status.BAD_REQUEST); } if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) { event.error(Errors.SESSION_EXPIRED); @@ -184,7 +206,8 @@ if (client == null) { client = clientSession.getClient(); } else { } else if (!prefix.equals(CASLoginProtocol.PROXY_TICKET_PREFIX)) { // Proxy tickets are issued from the proxying client's session for a (possibly) different target service if (!client.getClientId().equals(clientSession.getClient().getClientId())) { event.error(Errors.INVALID_CODE); throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Invalid service", Response.Status.BAD_REQUEST); @@ -201,28 +224,49 @@ throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST); } // Single logout refers to the service ticket the client session was established with if (prefix.equals(CASLoginProtocol.SERVICE_TICKET_PREFIX)) { clientSession.setNote(CASLoginProtocol.SESSION_TICKET, ticket); } } protected void createProxyGrant(String pgtUrl) { if ( RedirectUtils.verifyRedirectUri(session, pgtUrl, client) == null ) { String verifiedPgtUrl = RedirectUtils.verifyRedirectUri(session, pgtUrl, client); if (verifiedPgtUrl == null) { event.error(Errors.INVALID_REQUEST); throw new CASValidationException(CASErrorCode.INVALID_PROXY_CALLBACK, "Proxy callback is invalid", Response.Status.BAD_REQUEST); } // The callback receives a PGT, so require HTTPS unless the realm's "Require SSL" setting allows plain HTTP for the host URI pgtUri = URI.create(verifiedPgtUrl); boolean isHttps = "https".equalsIgnoreCase(pgtUri.getScheme()); boolean isHttp = "http".equalsIgnoreCase(pgtUri.getScheme()); if (pgtUri.getHost() == null || !(isHttps || (isHttp && !realm.getSslRequired().isRequired(pgtUri.getHost())))) { event.error(Errors.INVALID_REQUEST); throw new CASValidationException(CASErrorCode.INVALID_PROXY_CALLBACK, "Proxy callback must use HTTPS", Response.Status.BAD_REQUEST); } String pgtIou = getPGTIOU(); String pgtId = getPGT(session, clientSession, pgtUrl); String pgtId = getPGT(session, clientSession, verifiedPgtUrl); try { HttpResponse response = HttpClientBuilder.create().build().execute( new HttpGet(new URIBuilder(pgtUrl).setParameter("pgtIou",pgtIou).setParameter("pgtId",pgtId).build()) ); HttpGet request = new HttpGet(new URIBuilder(verifiedPgtUrl).setParameter("pgtIou",pgtIou).setParameter("pgtId",pgtId).build()); request.setConfig(PGT_CALLBACK_REQUEST_CONFIG); if (response.getStatusLine().getStatusCode() != 200) { throw new Exception(); HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); HttpResponse response = httpClient.execute(request); try { if (response.getStatusLine().getStatusCode() != 200) { throw new Exception(); } } finally { EntityUtils.consumeQuietly(response.getEntity()); } this.pgtIou = pgtIou; } catch (Exception e) { // The PGT was not delivered, so do not leave it usable session.singleUseObjects().remove(pgtId.substring(0, pgtId.indexOf("--"))); event.error(Errors.INVALID_REQUEST); throw new CASValidationException(CASErrorCode.PROXY_CALLBACK_ERROR, "Proxy callback returned an error", Response.Status.BAD_REQUEST); } @@ -286,7 +330,12 @@ } private String hexDecode(String hex) { byte[] bytes = HexFormat.of().parseHex(hex); return new String(bytes, StandardCharsets.US_ASCII); try { byte[] bytes = HexFormat.of().parseHex(hex); return new String(bytes, StandardCharsets.US_ASCII); } catch (IllegalArgumentException e) { event.error(Errors.INVALID_CODE); throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Invalid format of the code", Response.Status.BAD_REQUEST); } } } src/main/java/org/keycloak/protocol/cas/endpoints/ProxyEndpoint.java
@@ -5,12 +5,15 @@ import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.*; import org.keycloak.protocol.cas.CASLoginProtocol; import org.keycloak.protocol.cas.representations.CASErrorCode; import org.keycloak.protocol.cas.representations.CASServiceResponse; import org.keycloak.protocol.cas.utils.CASValidationException; import org.keycloak.protocol.cas.utils.RedirectUtils; import org.keycloak.protocol.cas.utils.ContentTypeHelper; import org.keycloak.protocol.cas.utils.ServiceResponseHelper; @@ -33,13 +36,29 @@ checkSsl(); checkRealm(); checkTicket(pgt, CASLoginProtocol.PROXY_GRANTING_TICKET_PREFIX, false); String verifiedTargetService = checkTargetService(targetService); event.success(); return successResponse(getPT(this.session, clientSession, targetService)); return successResponse(getPT(this.session, clientSession, verifiedTargetService)); } catch (CASValidationException e) { return errorResponse(e); } } private String checkTargetService(String targetService) { if (targetService == null) { event.error(Errors.INVALID_REQUEST); throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TARGET_SERVICE_PARAM, Response.Status.BAD_REQUEST); } ClientModel targetClient = findClient(targetService); if (targetClient == null || !targetClient.isEnabled()) { event.error(Errors.CLIENT_NOT_FOUND); throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Target service not allowed", Response.Status.BAD_REQUEST); } return RedirectUtils.verifyRedirectUri(session, targetService, targetClient); } protected Response successResponse(String pt) { CASServiceResponse serviceResponse = ServiceResponseHelper.createProxySuccess(pt); return prepare(Response.Status.OK, serviceResponse); src/main/java/org/keycloak/protocol/cas/endpoints/ProxyValidateEndpoint.java
@@ -6,12 +6,10 @@ import jakarta.ws.rs.core.Response; import java.util.Map; import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.*; import org.keycloak.protocol.cas.CASLoginProtocol; import org.keycloak.protocol.cas.representations.CASErrorCode; import org.keycloak.protocol.cas.representations.CASServiceResponse; import org.keycloak.protocol.cas.utils.CASValidationException; import org.keycloak.protocol.cas.utils.ContentTypeHelper; @@ -27,6 +25,7 @@ @NoCache public Response build() { MultivaluedMap<String, String> params = session.getContext().getUri().getQueryParameters(); String service = params.getFirst(CASLoginProtocol.SERVICE_PARAM); String ticket = params.getFirst(CASLoginProtocol.TICKET_PARAM); String pgtUrl = params.getFirst(CASLoginProtocol.PGTURL_PARAM); boolean renew = params.containsKey(CASLoginProtocol.RENEW_PARAM); @@ -34,17 +33,12 @@ event.event(EventType.CODE_TO_TOKEN); try { String prefix = ticket.startsWith(CASLoginProtocol.PROXY_TICKET_PREFIX)? CASLoginProtocol.PROXY_TICKET_PREFIX:( ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)? CASLoginProtocol.SERVICE_TICKET_PREFIX : null ); if (prefix == null) { event.error(Errors.INVALID_CODE); throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST); } String prefix = ticket != null && ticket.startsWith(CASLoginProtocol.PROXY_TICKET_PREFIX) ? CASLoginProtocol.PROXY_TICKET_PREFIX : CASLoginProtocol.SERVICE_TICKET_PREFIX; checkSsl(); checkRealm(); checkClient(service); checkTicket(ticket, prefix, renew); if (pgtUrl != null) createProxyGrant(pgtUrl); event.success();