mirror of https://github.com/jacekkow/keycloak-protocol-cas

Alexandre Rocha Wendling
2024-06-26 755fd78fa0ee0f2a67417a119382c63e02c1091e
Proxy ticket service and proxy ticket validation
Proxy endpoints improvements suggested by Jacek Kowalski
Add ticket type to storage key
Rename isreuse to isReusable
Remove "parsing" of "codeUUID" that is String, not UUID
Improve error reporting in CAS ticket validation
13 files modified
4 files added
409 ■■■■ changed files
README.md 1 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java 19 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java 7 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java 148 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ProxyEndpoint.java 57 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ProxyValidateEndpoint.java 73 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java 2 ●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java 2 ●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java 5 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/representations/CASErrorCode.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponse.java 18 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponseProxyFailure.java 30 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponseProxySuccess.java 18 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/utils/AttributesMapAdapter.java 2 ●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/utils/CASValidationException.java 1 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/utils/ServiceResponseHelper.java 20 ●●●●● patch | view | raw | blame | history
src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java 4 ●●●● patch | view | raw | blame | history
README.md
@@ -17,7 +17,6 @@
The following features are **missing**:
* SAML request/response [CAS 3.0 - optional]
* Proxy ticket service and proxy ticket validation [CAS 2.0]
The following features are out of scope:
* Long-Term Tickets - Remember-Me [CAS 3.0 - optional]
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java
@@ -6,23 +6,20 @@
import org.apache.http.HttpEntity;
import org.jboss.logging.Logger;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.*;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.cas.endpoints.AbstractValidateEndpoint;
import org.keycloak.protocol.cas.utils.LogoutHelper;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.managers.ResourceAdminManager;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.io.IOException;
import java.net.URI;
import java.util.UUID;
public class CASLoginProtocol implements LoginProtocol {
    private static final Logger logger = Logger.getLogger(CASLoginProtocol.class);
@@ -35,11 +32,17 @@
    public static final String GATEWAY_PARAM = "gateway";
    public static final String TICKET_PARAM = "ticket";
    public static final String FORMAT_PARAM = "format";
    public static final String PGTURL_PARAM = "pgtUrl";
    public static final String TARGET_SERVICE_PARAM = "targetService";
    public static final String PGT_PARAM = "pgt";
    public static final String TICKET_RESPONSE_PARAM = "ticket";
    public static final String SAMLART_RESPONSE_PARAM = "SAMLart";
    public static final String SERVICE_TICKET_PREFIX = "ST-";
    public static final String PROXY_GRANTING_TICKET_IOU_PREFIX = "PGTIOU-";
    public static final String PROXY_GRANTING_TICKET_PREFIX = "PGT-";
    public static final String PROXY_TICKET_PREFIX = "PT-";
    public static final String SESSION_SERVICE_TICKET = "service_ticket";
    public static final String LOGOUT_REDIRECT_URI = "CAS_LOGOUT_REDIRECT_URI";
@@ -98,15 +101,9 @@
        String service = authSession.getRedirectUri();
        //TODO validate service
        OAuth2Code codeData = new OAuth2Code(UUID.randomUUID().toString(),
                Time.currentTime() + userSession.getRealm().getAccessCodeLifespan(),
                null, null, authSession.getRedirectUri(), null, null,
                userSession.getId());
        String code = OAuth2CodeParser.persistCode(session, clientSession, codeData);
        KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(service);
        String loginTicket = SERVICE_TICKET_PREFIX + code;
        String loginTicket = AbstractValidateEndpoint.getST(session, clientSession, service);
        if (authSession.getClientNotes().containsKey(CASLoginProtocol.TARGET_PARAM)) {
            // This was a SAML 1.1 auth request so return the ticket ID as "SAMLart" instead of "ticket"
src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java
@@ -1,8 +1,8 @@
package org.keycloak.protocol.cas;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -51,13 +51,12 @@
    @Path("proxyValidate")
    public Object proxyValidate() {
        //TODO implement
        return serviceValidate();
        return new ProxyValidateEndpoint(session, realm, event);
    }
    @Path("proxy")
    public Object proxy() {
        return Response.serverError().entity("Not implemented").build();
        return new ProxyEndpoint(session, realm, event);
    }
    @Path("p3/serviceValidate")
src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java
@@ -5,29 +5,39 @@
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.common.util.Time;
import org.keycloak.models.*;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.mappers.CASAttributeMapper;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.util.DefaultClientSessionContext;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
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;
public abstract class AbstractValidateEndpoint {
    protected final Logger logger = Logger.getLogger(getClass());
    private static final Pattern DOT = Pattern.compile("\\.");
    protected KeycloakSession session;
    protected RealmModel realm;
    protected EventBuilder event;
    protected ClientModel client;
    protected AuthenticatedClientSessionModel clientSession;
    protected String pgtIou;
    public AbstractValidateEndpoint(KeycloakSession session, RealmModel realm, EventBuilder event) {
        this.session = session;
@@ -74,50 +84,78 @@
        session.getContext().setClient(client);
    }
    protected void checkTicket(String ticket, boolean requireReauth) {
    protected void checkTicket(String ticket, String prefix, boolean requireReauth) {
        if (ticket == null) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TICKET_PARAM, Response.Status.BAD_REQUEST);
        }
        if (!ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)) {
        if (!ticket.startsWith(prefix)) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST);
        }
        String code = ticket.substring(CASLoginProtocol.SERVICE_TICKET_PREFIX.length());
        boolean isReusable = ticket.startsWith(CASLoginProtocol.PROXY_GRANTING_TICKET_PREFIX);
        OAuth2CodeParser.ParseResult parseResult = OAuth2CodeParser.parseCode(session, code, realm, event);
        if (parseResult.isIllegalCode()) {
        String[] parsed = DOT.split(ticket.substring(prefix.length()), 3);
        if (parsed.length != 3) {
            event.error(Errors.INVALID_CODE);
            // Attempt to use same code twice should invalidate existing clientSession
            AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();
            if (clientSession != null) {
                clientSession.detachFromUserSession();
            throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Invalid format of the code", Response.Status.BAD_REQUEST);
            }
        String codeUUID = parsed[0];
        String userSessionId = parsed[1];
        String clientUUID = parsed[2];
        event.detail(Details.CODE_ID, userSessionId);
        event.session(userSessionId);
        // Retrieve UserSession
        UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, userSessionId, clientUUID);
        if (userSession == null) {
            // Needed to track if code is invalid
            userSession = session.sessions().getUserSession(realm, userSessionId);
            if (userSession == null) {
                event.error(Errors.USER_SESSION_NOT_FOUND);
                throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
            }
        }
        clientSession = userSession.getAuthenticatedClientSessionByClient(clientUUID);
        if (clientSession == null) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
        }
        clientSession = parseResult.getClientSession();
        SingleUseObjectProvider codeStore = session.singleUseObjects();
        Map<String, String> codeDataSerialized = isReusable ? codeStore.get(prefix + codeUUID) : codeStore.remove(prefix + codeUUID);
        if (parseResult.isExpiredCode()) {
        // Either code not available
        if (codeDataSerialized == null) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
        }
        OAuth2Code codeData = OAuth2Code.deserializeCode(codeDataSerialized);
        String persistedUserSessionId = codeData.getUserSessionId();
        if (!userSessionId.equals(persistedUserSessionId)) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
        }
        // Finally doublecheck if code is not expired
        int currentTime = Time.currentTime();
        if (currentTime > codeData.getExpiration()) {
            event.error(Errors.EXPIRED_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST);
        }
        clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket);
        clientSession.setNote(prefix, ticket);
        if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) {
            event.error(Errors.SESSION_EXPIRED);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Interactive authentication was requested but not performed", Response.Status.BAD_REQUEST);
        }
        UserSessionModel userSession = clientSession.getUserSession();
        if (userSession == null) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User session not found", Response.Status.BAD_REQUEST);
        }
        UserModel user = userSession.getUser();
@@ -133,14 +171,44 @@
        event.user(userSession.getUser());
        event.session(userSession.getId());
        if (client == null) {
            client = clientSession.getClient();
        } else {
        if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Auth error", Response.Status.BAD_REQUEST);
                throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Invalid service", Response.Status.BAD_REQUEST);
            }
        }
        if (!AuthenticationManager.isSessionValid(realm, userSession)) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST);
        }
    }
    protected void createProxyGrant(String pgtUrl) {
        if ( RedirectUtils.verifyRedirectUri(session, pgtUrl, client) == null ) {
            event.error(Errors.INVALID_REQUEST);
            throw new CASValidationException(CASErrorCode.INVALID_PROXY_CALLBACK, "Proxy callback is invalid", Response.Status.BAD_REQUEST);
        }
        String pgtIou = getPGTIOU();
        String pgtId  = getPGT(session, clientSession, pgtUrl);
        try {
            HttpResponse response = HttpClientBuilder.create().build().execute(
                new HttpGet(new URIBuilder(pgtUrl).setParameter("pgtIou",pgtIou).setParameter("pgtId",pgtId).build())
            );
            if (response.getStatusLine().getStatusCode() != 200) {
                throw new Exception();
            }
            this.pgtIou = pgtIou;
        } catch (Exception e) {
            event.error(Errors.INVALID_REQUEST);
            throw new CASValidationException(CASErrorCode.PROXY_CALLBACK_ERROR, "Proxy callback returned an error", Response.Status.BAD_REQUEST);
        }
    }
@@ -160,4 +228,40 @@
        }
        return attributes;
    }
    protected String getPGTIOU()
    {
        return CASLoginProtocol.PROXY_GRANTING_TICKET_IOU_PREFIX + UUID.randomUUID().toString();
    }
    protected String getPGT(KeycloakSession session, AuthenticatedClientSessionModel clientSession, String pgtUrl)
    {
        return persistedTicket(pgtUrl, CASLoginProtocol.PROXY_GRANTING_TICKET_PREFIX);
    }
    protected String getPT(KeycloakSession session, AuthenticatedClientSessionModel clientSession, String targetService)
    {
        return persistedTicket(targetService, CASLoginProtocol.PROXY_TICKET_PREFIX);
    }
    protected String getST(String redirectUri)
    {
        return persistedTicket(redirectUri, CASLoginProtocol.SERVICE_TICKET_PREFIX);
    }
    public static String getST(KeycloakSession session, AuthenticatedClientSessionModel clientSession, String redirectUri)
    {
        ValidateEndpoint vp = new ValidateEndpoint(session,null,null);
        vp.clientSession = clientSession;
        return vp.getST(redirectUri);
    }
    protected String persistedTicket(String redirectUriParam, String prefix)
    {
        String key = UUID.randomUUID().toString();
        UserSessionModel userSession = clientSession.getUserSession();
        OAuth2Code codeData = new OAuth2Code(key, Time.currentTime() + userSession.getRealm().getAccessCodeLifespan(), null, null, redirectUriParam, null, null, userSession.getId());
        session.singleUseObjects().put(prefix + key, clientSession.getUserSession().getRealm().getAccessCodeLifespan(), codeData.serializeCode());
        return prefix + key + "." + clientSession.getUserSession().getId() + "." + clientSession.getClient().getId();
    }
}
src/main/java/org/keycloak/protocol/cas/endpoints/ProxyEndpoint.java
New file
@@ -0,0 +1,57 @@
package org.keycloak.protocol.cas.endpoints;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.annotations.cache.NoCache;
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.CASServiceResponse;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.cas.utils.ContentTypeHelper;
import org.keycloak.protocol.cas.utils.ServiceResponseHelper;
public class ProxyEndpoint extends AbstractValidateEndpoint {
    public ProxyEndpoint(KeycloakSession session, RealmModel realm, EventBuilder event) {
        super(session, realm, event);
    }
    @GET
    @NoCache
    public Response build() {
        MultivaluedMap<String, String> params = session.getContext().getUri().getQueryParameters();
        String targetService = params.getFirst(CASLoginProtocol.TARGET_SERVICE_PARAM);
        String pgt = params.getFirst(CASLoginProtocol.PGT_PARAM);
        event.event(EventType.CODE_TO_TOKEN);
        try {
            checkSsl();
            checkRealm();
            checkTicket(pgt, CASLoginProtocol.PROXY_GRANTING_TICKET_PREFIX, false);
            event.success();
            return successResponse(getPT(this.session, clientSession, targetService));
        } catch (CASValidationException e) {
            return errorResponse(e);
        }
    }
    protected Response successResponse(String pt) {
        CASServiceResponse serviceResponse = ServiceResponseHelper.createProxySuccess(pt);
        return prepare(Response.Status.OK, serviceResponse);
    }
    protected Response errorResponse(CASValidationException e) {
        CASServiceResponse serviceResponse = ServiceResponseHelper.createProxyFailure(e.getError(), e.getErrorDescription());
        return prepare(e.getStatus(), serviceResponse);
    }
    private Response prepare(Response.Status status, CASServiceResponse serviceResponse) {
        MediaType responseMediaType = new ContentTypeHelper(session.getContext().getUri()).selectResponseType();
        return ServiceResponseHelper.createResponse(status, responseMediaType, serviceResponse);
    }
}
src/main/java/org/keycloak/protocol/cas/endpoints/ProxyValidateEndpoint.java
New file
@@ -0,0 +1,73 @@
package org.keycloak.protocol.cas.endpoints;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
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;
import org.keycloak.protocol.cas.utils.ServiceResponseHelper;
public class ProxyValidateEndpoint extends AbstractValidateEndpoint {
    public ProxyValidateEndpoint(KeycloakSession session,RealmModel realm, EventBuilder event) {
        super(session, realm, event);
    }
    @GET
    @NoCache
    public Response build() {
        MultivaluedMap<String, String> params = session.getContext().getUri().getQueryParameters();
        String ticket = params.getFirst(CASLoginProtocol.TICKET_PARAM);
        String pgtUrl = params.getFirst(CASLoginProtocol.PGTURL_PARAM);
        boolean renew = params.containsKey(CASLoginProtocol.RENEW_PARAM);
        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);
            }
            checkSsl();
            checkRealm();
            checkTicket(ticket, prefix, renew);
            if (pgtUrl != null) createProxyGrant(pgtUrl);
            event.success();
            return successResponse();
        } catch (CASValidationException e) {
            return errorResponse(e);
        }
    }
    protected Response successResponse() {
        UserSessionModel userSession = clientSession.getUserSession();
        Map<String, Object> attributes = getUserAttributes();
        CASServiceResponse serviceResponse = ServiceResponseHelper.createSuccess(userSession.getUser().getUsername(),attributes);
        return prepare(Response.Status.OK, serviceResponse);
    }
    protected Response errorResponse(CASValidationException e) {
        CASServiceResponse serviceResponse = ServiceResponseHelper.createFailure(e.getError(), e.getErrorDescription());
        return prepare(e.getStatus(), serviceResponse);
    }
    private Response prepare(Response.Status status, CASServiceResponse serviceResponse) {
        MediaType responseMediaType = new ContentTypeHelper(session.getContext().getUri()).selectResponseType();
        return ServiceResponseHelper.createResponse(status, responseMediaType, serviceResponse);
    }
}
src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java
@@ -56,7 +56,7 @@
            String issuer = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName());
            String ticket = getTicket(input);
            checkTicket(ticket, renew);
            checkTicket(ticket, CASLoginProtocol.SERVICE_TICKET_PREFIX, renew);
            UserModel user = clientSession.getUserSession().getUser();
            Map<String, Object> attributes = getUserAttributes();
src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java
@@ -22,7 +22,7 @@
    protected Response successResponse() {
        UserSessionModel userSession = clientSession.getUserSession();
        Map<String, Object> attributes = getUserAttributes();
        CASServiceResponse serviceResponse = ServiceResponseHelper.createSuccess(userSession.getUser().getUsername(), attributes);
        CASServiceResponse serviceResponse = ServiceResponseHelper.createSuccess(userSession.getUser().getUsername(), attributes, this.pgtIou, null);
        return prepare(Response.Status.OK, serviceResponse);
    }
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java
@@ -26,6 +26,7 @@
    public Response build() {
        MultivaluedMap<String, String> params = session.getContext().getUri().getQueryParameters();
        String service = params.getFirst(CASLoginProtocol.SERVICE_PARAM);
        String pgtUrl = params.getFirst(CASLoginProtocol.PGTURL_PARAM);
        String ticket = params.getFirst(CASLoginProtocol.TICKET_PARAM);
        boolean renew = params.containsKey(CASLoginProtocol.RENEW_PARAM);
@@ -36,7 +37,9 @@
            checkRealm();
            checkClient(service);
            checkTicket(ticket, renew);
            checkTicket(ticket, CASLoginProtocol.SERVICE_TICKET_PREFIX, renew);
            if (pgtUrl != null) createProxyGrant(pgtUrl);
            event.success();
            return successResponse();
src/main/java/org/keycloak/protocol/cas/representations/CASErrorCode.java
@@ -9,6 +9,8 @@
    UNAUTHORIZED_SERVICE_PROXY,
    /** The proxy callback specified is invalid. The credentials specified for proxy authentication do not meet the security requirements */
    INVALID_PROXY_CALLBACK,
    /** The proxy callback specified return with error*/
    PROXY_CALLBACK_ERROR,
    /** the ticket provided was not valid, or the ticket did not come from an initial login and renew was set on validation. */
    INVALID_TICKET,
    /** the ticket provided was valid, but the service specified did not match the service associated with the ticket. */
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponse.java
@@ -6,6 +6,8 @@
public class CASServiceResponse {
    private CASServiceResponseAuthenticationFailure authenticationFailure;
    private CASServiceResponseAuthenticationSuccess authenticationSuccess;
    private CASServiceResponseProxySuccess proxySuccess;
    private CASServiceResponseProxyFailure proxyFailure;
    public CASServiceResponseAuthenticationFailure getAuthenticationFailure() {
        return this.authenticationFailure;
@@ -22,4 +24,20 @@
    public void setAuthenticationSuccess(final CASServiceResponseAuthenticationSuccess authenticationSuccess) {
        this.authenticationSuccess = authenticationSuccess;
    }
    public CASServiceResponseProxySuccess getProxySuccess() {
        return this.proxySuccess;
    }
    public void setProxySuccess(final CASServiceResponseProxySuccess proxySuccess) {
        this.proxySuccess = proxySuccess;
    }
    public CASServiceResponseProxyFailure getProxyFailure() {
        return this.proxyFailure;
    }
    public void setProxyFailure(final CASServiceResponseProxyFailure proxyFailure) {
        this.proxyFailure = proxyFailure;
    }
}
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponseProxyFailure.java
New file
@@ -0,0 +1,30 @@
package org.keycloak.protocol.cas.representations;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlAttribute;
import jakarta.xml.bind.annotation.XmlValue;
@XmlAccessorType(XmlAccessType.FIELD)
public class CASServiceResponseProxyFailure {
    @XmlAttribute
    private String code;
    @XmlValue
    private String description;
    public String getCode() {
        return this.code;
    }
    public void setCode(final String code) {
        this.code = code;
    }
    public String getDescription() {
        return this.description;
    }
    public void setDescription(final String description) {
        this.description = description;
    }
}
src/main/java/org/keycloak/protocol/cas/representations/CASServiceResponseProxySuccess.java
New file
@@ -0,0 +1,18 @@
package org.keycloak.protocol.cas.representations;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
@XmlAccessorType(XmlAccessType.FIELD)
public class CASServiceResponseProxySuccess {
    private String proxyTicket;
    public String getProxyTicket() {
        return this.proxyTicket;
    }
    public void setProxyTicket(final String proxyTicket) {
        this.proxyTicket = proxyTicket;
    }
}
src/main/java/org/keycloak/protocol/cas/utils/AttributesMapAdapter.java
@@ -39,7 +39,7 @@
            this.elements = new ArrayList<>();
            for (Map.Entry<String, Object> entry : attributes.entrySet()) {
                if (entry.getValue() instanceof Collection) {
                    for (Object item : ((Collection) entry.getValue())) {
                    for (Object item : ((Collection<?>) entry.getValue())) {
                        addElement(entry.getKey(), item);
                    }
                } else {
src/main/java/org/keycloak/protocol/cas/utils/CASValidationException.java
@@ -5,6 +5,7 @@
import org.keycloak.protocol.cas.representations.CASErrorCode;
public class CASValidationException extends WebApplicationException {
    private static final long serialVersionUID = 4929825917145240776L;
    private final CASErrorCode error;
    private final String errorDescription;
    private final Response.Status status;
src/main/java/org/keycloak/protocol/cas/utils/ServiceResponseHelper.java
@@ -7,6 +7,8 @@
import org.keycloak.protocol.cas.representations.CASServiceResponse;
import org.keycloak.protocol.cas.representations.CASServiceResponseAuthenticationFailure;
import org.keycloak.protocol.cas.representations.CASServiceResponseAuthenticationSuccess;
import org.keycloak.protocol.cas.representations.CASServiceResponseProxySuccess;
import org.keycloak.protocol.cas.representations.CASServiceResponseProxyFailure;
import java.util.List;
import java.util.Map;
@@ -43,6 +45,24 @@
        return response;
    }
    public static CASServiceResponse createProxySuccess(String pt) {
        CASServiceResponse response = new CASServiceResponse();
        CASServiceResponseProxySuccess success = new CASServiceResponseProxySuccess();
        success.setProxyTicket(pt);
        response.setProxySuccess(success);
        return response;
    }
    public static CASServiceResponse createProxyFailure(CASErrorCode errorCode, String errorDescription) {
        CASServiceResponse response = new CASServiceResponse();
        CASServiceResponseProxyFailure failure = new CASServiceResponseProxyFailure();
        failure.setCode(errorCode == null ? CASErrorCode.INTERNAL_ERROR.name() : errorCode.name());
        failure.setDescription(errorDescription);
        response.setProxyFailure(failure);
        return response;
    }
    public static Response createResponse(Response.Status status, MediaType mediaType, CASServiceResponse serviceResponse) {
        Response.ResponseBuilder builder = Response.status(status)
                .header(HttpHeaders.CONTENT_TYPE, mediaType.withCharset("utf-8"));
src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java
@@ -52,10 +52,10 @@
        assertEquals("username", xpath.evaluate("/cas:serviceResponse/cas:authenticationSuccess/cas:user", doc));
        int idx = 0;
        for (Node node : xpath.selectNodes("/cas:serviceResponse/cas:authenticationSuccess/cas:attributes/cas:list", doc)) {
            assertEquals(((List)attributes.get("list")).get(idx), node.getTextContent());
            assertEquals(((List<?>)attributes.get("list")).get(idx), node.getTextContent());
            idx++;
        }
        assertEquals(((List)attributes.get("list")).size(), idx);
        assertEquals(((List<?>)attributes.get("list")).size(), idx);
        assertEquals(attributes.get("int").toString(), xpath.evaluate("/cas:serviceResponse/cas:authenticationSuccess/cas:attributes/cas:int", doc));
        assertEquals(attributes.get("string").toString(), xpath.evaluate("/cas:serviceResponse/cas:authenticationSuccess/cas:attributes/cas:string", doc));