8000 Apply browser security headers on SP-initiated SAML endpoints by edewit · Pull Request #54159 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.core.MultivaluedMap;

import org.keycloak.provider.Provider;

Expand All @@ -27,6 +28,14 @@ public interface SecurityHeadersProvider extends Provider {

void addHeaders(ContainerRequestContext requestContext, ContainerResponseContext responseContext);

/**
* Apply browser security headers to a response header map while a session is available.
* Respects {@link #options()} (for example {@code allowFrameSrc} for front-channel logout).
* Used when an async response is resumed after the session is closed and the security-headers
* filter cannot run.
*/
void addHeaders(MultivaluedMap<String, Object> responseHeaders);

@Override
default void close() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public void addHeaders(ContainerRequestContext requestContext, ContainerResponse
}
}

@Override
public void addHeaders(MultivaluedMap<String, Object> responseHeaders) {
if (options != null && options.isSkipHeaders()) {
return;
}
// HTML path so per-response options (allowFrameSrc, allowAnyFrameAncestor) are applied.
addHtmlHeaders(responseHeaders);
}

private void addGenericHeaders(MultivaluedMap<String, Object> headers) {
addHeader(BrowserSecurityHeaders.STRICT_TRANSPORT_SECURITY, headers);
addHeader(BrowserSecurityHeaders.X_CONTENT_TYPE_OPTIONS, headers);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
import java.util.function.BiConsumer;
import java.util.function.Predicate;

import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.models.KeycloakSession;

public final class SecurityHeadersUtils {

Expand All @@ -39,6 +44,34 @@ public static void addHeaderIfAbsent(BrowserSecurityHeaders header, Map<String,
writeIfAbsent(header, sourceHeaders.getOrDefault(header.getKey(), header.getDefaultValue()), hasHeader, headerWriter);
}

/**
* Apply realm browser security headers while a session is available, returning a rebuilt {@link Response}.
* Needed for AsyncResponse flows where {@code KeycloakSecurityHeadersFilter} runs after the session is closed.
* Rebuilds the response rather than mutating {@code response.getHeaders()}, which Quarkus REST does not
* propagate when an AsyncResponse is resumed. Existing values for applied header names are replaced
* (matching {@code putSingle}) so a prior CSP is not kept alongside the realm policy.
*/
public static Response applyBrowserSecurityHeaders(KeycloakSession session, Response response) {
if (response == null || session.getContext().getRealm() == null) {
return response;
}
SecurityHeadersProvider securityHeaders = session.getProvider(SecurityHeadersProvider.class);
if (securityHeaders == null) {
return response;
}
MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
securityHeaders.addHeaders(headers);
if (headers.isEmpty()) {
return response;
}
Response.ResponseBuilder builder = Response.fromResponse(response);
headers.forEach((name, values) -> {
builder.header(name, null); // remove values copied by fromResponse
values.forEach(value -> builder.header(name, value));
});
return builder.build();
}

private static void writeIfAbsent(BrowserSecurityHeaders header, String value, Predicate<String> hasHeader, BiConsumer<String, String> headerWriter) {
String headerName = header.getHeaderName();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.executors.ExecutorsProvider;
import org.keycloak.headers.SecurityHeadersUtils;
import org.keycloak.http.HttpRequest;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.AuthenticatedClientSessionModel;
Expand Down Expand Up @@ -383,9 +384,12 @@ protected Response handleSamlRequest(String samlRequest, String relayState) {
* sending an ArtifactResolve, receiving an ArtifactResponse, and handling its content based on the "standard"
* workflows.
*
* <p>SOAP artifact resolution runs on {@code saml-artifact-pool} so a slow SP does not block a shared HTTP
* worker. {@link AsyncResponseTransaction} applies browser security headers to the response that is actually
* resumed after commit or rollback.
*
* @param artifact the received artifact
* @param relayState the current relay state
* @return a Response based on the content of the ArtifactResponse's content
*/
protected void handleArtifact(AsyncResponse asyncResponse, String artifact, String relayState) {
logger.tracef("Keycloak obtained artifact %s. %s", artifact, getShortStackTrace());
Expand All @@ -396,15 +400,15 @@ protected void handleArtifact(AsyncResponse asyncResponse, String artifact, Stri

Response error = checkClientValidity(client);
if (error != null) {
asyncResponse.resume(error);
asyncResponse.resume(withBrowserSecurityHeaders(session, error));
return;
}

} catch (ArtifactResolverProcessingException | IllegalArgumentException e) {
event.event(EventType.LOGIN);
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_SAML_ARTIFACT);
asyncResponse.resume(error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST));
asyncResponse.resume(withBrowserSecurityHeaders(session, error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST)));
return;
}

Expand Down Expand Up @@ -442,8 +446,7 @@ protected void handleArtifact(AsyncResponse asyncResponse, String artifact, Stri
event.event(EventType.LOGIN);
event.detail(Details.REASON, e.getMessage());
event.error(Errors.IDENTITY_PROVIDER_ERROR);
asyncResponse.resume(error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST));
return;
asyncResponse.resume(withBrowserSecurityHeaders(session, error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST)));
}
}

Expand Down Expand Up @@ -789,23 +792,27 @@ public Response execute(String samlRequest, String samlResponse, String relaySta
return handleSamlResponse(samlResponse, relayState);
}

public void execute(AsyncResponse asyncReponse, String samlRequest, String samlResponse, String relayState, String artifact) {
/**
* Async entry point used by GET/POST bindings. Non-artifact flows resume immediately with browser security
* headers applied (AsyncResponse otherwise skips {@code KeycloakSecurityHeadersFilter}). Artifact resolution
* is scheduled on {@code saml-artifact-pool}.
*/
public void execute(AsyncResponse asyncResponse, String samlRequest, String samlResponse, String relayState, String artifact) {
Response response = basicChecks(samlRequest, samlResponse, artifact);

if (response != null){
asyncReponse.resume(response);
if (response != null) {
asyncResponse.resume(withBrowserSecurityHeaders(session, response));
return;
}

if (artifact != null) {
handleArtifact(asyncReponse, artifact, relayState);
handleArtifact(asyncResponse, artifact, relayState);
return;
}
if (samlRequest != null) {
asyncReponse.resume(handleSamlRequest(samlRequest, relayState));
return;
asyncResponse.resume(withBrowserSecurityHeaders(session, handleSamlRequest(samlRequest, relayState)));
} else {
asyncReponse.resume(handleSamlResponse(samlResponse, relayState));
asyncResponse.resume(withBrowserSecurityHeaders(session, handleSamlResponse(samlResponse, relayState)));
}
}

Expand Down Expand Up @@ -974,6 +981,9 @@ public PostBindingProtocol newPostBindingProtocol() {
}

/**
* SP-initiated SAML HTTP-Redirect binding. Resumes with realm browser security headers applied explicitly
* because {@link AsyncResponse} closes the session before {@link org.keycloak.services.filters.KeycloakSecurityHeadersFilter}.
* Artifact resolution is offloaded to {@code saml-artifact-pool}.
*/
@GET
public void redirectBinding(@Suspended AsyncResponse asyncResponse, @QueryParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, @QueryParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, @QueryParam(GeneralConstants.RELAY_STATE) String relayState, @QueryParam(GeneralConstants.SAML_ARTIFACT_KEY) String artifact) {
Expand All @@ -984,6 +994,9 @@ public void redirectBinding(@Suspended AsyncResponse asyncResponse, @QueryParam(
}

/**
* SP-initiated SAML HTTP-POST binding. Resumes with realm browser security headers applied explicitly
* because {@link AsyncResponse} closes the session before {@link org.keycloak.services.filters.KeycloakSecurityHeadersFilter}.
* Artifact resolution is offloaded to {@code saml-artifact-pool}.
*/
@POST
@NoCache
Expand Down Expand Up @@ -1449,24 +1462,30 @@ private Document createArtifactResolve(String issuer, String artifact) throws Pr
return DocumentUtil.getDocument(new ByteArrayInputStream(bos.toByteArray()));
}


private static Response withBrowserSecurityHeaders(KeycloakSession session, Response response) {
return SecurityHeadersUtils.applyBrowserSecurityHeaders(session, response);
}

private ArtifactResolver getArtifactResolver(String artifact) {
ArtifactResolver artifactResolver = session.getProvider(ArtifactResolver.class, ArtifactBindingUtils.artifactToResolverProviderId(artifact));
return artifactResolver != null ? artifactResolver : session.getProvider(ArtifactResolver.class);
}

private class ArtifactResolutionRunnable implements ScheduledTask{

private class ArtifactResolutionRunnable implements ScheduledTask {

private final HttpRequest request;
private final HttpResponse response;
private AsyncResponse asyncResponse;
private URI clientArtifactBindingURI;
private String relayState;
private Document doc;
private String realmId;
private ClientConnection connection;
private String bindingType;

public ArtifactResolutionRunnable(String bindingType, AsyncResponse asyncResponse, Document doc, URI clientArtifactBindingURI, String relayState){
private final AsyncResponse asyncResponse;
private final URI clientArtifactBindingURI;
private final String relayState;
private final Document doc;
private final String realmId;
private final ClientConnection connection;
private final String bindingType;

public ArtifactResolutionRunnable(String bindingType, AsyncResponse asyncResponse, Document doc, URI clientArtifactBindingURI, String relayState) {
this.asyncResponse = asyncResponse;
this.doc = doc;
this.clientArtifactBindingURI = clientArtifactBindingURI;
Expand All @@ -1479,7 +1498,7 @@ public ArtifactResolutionRunnable(String bindingType, AsyncResponse asyncRespons
}

@Override
public void run(KeycloakSession session){
public void run(KeycloakSession session) {
// Initialize context
session.getContext().setHttpRequest(request);
session.getContext().setHttpResponse(response);
Expand All @@ -1492,7 +1511,7 @@ public void run(KeycloakSession session){
}
session.getContext().setRealm(realm);

EventBuilder event = new EventBuilder(realm, session, clientConnection);
EventBuilder event = new EventBuilder(realm, session, connection);

// Call Artifact Resolution Service
HttpClientProvider httpClientProvider = session.getProvider(HttpClientProvider.class);
Expand Down Expand Up @@ -1569,7 +1588,7 @@ public void run(KeycloakSession session){

AsyncResponseTransaction.finishAsyncResponseInTransaction(session, asyncResponse,
ErrorPage.error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.ARTIFACT_RESOLUTION_SERVICE_INVALID_RESPONSE));
} catch(ConfigurationException e) {
} catch (ConfigurationException e) {
event.event(EventType.LOGIN);
event.detail(Details.REASON, e.getMessage());
event.error(Errors.IDENTITY_PROVIDER_ERROR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import jakarta.ws.rs.container.AsyncResponse;
import jakarta.ws.rs.core.Response;

import org.keycloak.headers.SecurityHeadersUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakTransaction;
import org.keycloak.models.KeycloakTransactionManager;
Expand All @@ -26,6 +27,9 @@ public class AsyncResponseTransaction implements KeycloakTransaction {
* {@code responseToFinishInTransaction} with given Response {@code responseToSend}. The transaction is enlisted
* to {@link KeycloakTransactionManager}.
*
* <p>Browser security headers are applied to the response that is actually resumed (after commit or rollback
* selects it), so a rollback-generated error page still receives realm security headers.</p>
*
* @param session Current KeycloakSession
* @param responseToFinishInTransaction AsyncResponse to be resumed on {@link KeycloakTransactionManager} commit/rollback.
* @param responseToSend Response to be sent
Expand All @@ -47,16 +51,17 @@ public void begin() {

@Override
public void commit() {
responseToFinishInTransaction.resume(responseToSend);
responseToFinishInTransaction.resume(SecurityHeadersUtils.applyBrowserSecurityHeaders(session, responseToSend));
}

@Override
public void rollback() {
// Preserve the original error response on rollback — the caller already chose the appropriate status.
// For success responses, override with 500 since the state they depend on was not committed.
responseToFinishInTransaction.resume(responseToSend.getStatus() >= 400
Response finalResponse = responseToSend.getStatus() >= 400
? responseToSend
: ErrorPage.error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.INTERNAL_SERVER_ERROR));
: ErrorPage.error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.INTERNAL_SERVER_ERROR);
responseToFinishInTransaction.resume(SecurityHeadersUtils.applyBrowserSecurityHeaders(session, finalResponse));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedList;
Expand Down Expand Up @@ -110,6 +111,18 @@ public HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayS
return createSamlPostMessage(samlEndpoint, relayState, samlResponse, GeneralConstants.SAML_RESPONSE_KEY);
}

@Override
public HttpUriRequest createSamlArtifactRequest(URI samlEndpoint, String relayState, String artifact) {
HttpPost post = new HttpPost(samlEndpoint);
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(GeneralConstants.SAML_ARTIFACT_KEY, artifact));
if (relayState != null) {
parameters.add(new BasicNameValuePair(RELAY_STATE, relayState));
}
post.setEntity(new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8));
return post;
}

private HttpPost createSamlPostMessage(URI samlEndpoint, String relayState, Document samlDoc, String messageType) {
HttpPost post = new HttpPost(samlEndpoint);
List<NameValuePair> parameters = new LinkedList<>();
Expand Down Expand Up @@ -152,6 +165,20 @@ public HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayS
return createSamlRedirectMessage(samlEndpoint, relayState, samlResponse, GeneralConstants.SAML_RESPONSE_KEY);
}

@Override
public HttpUriRequest createSamlArtifactRequest(URI samlEndpoint, String relayState, String artifact) {
try {
URIBuilder builder = new URIBuilder(samlEndpoint)
.setParameter(GeneralConstants.SAML_ARTIFACT_KEY, artifact);
if (relayState != null) {
builder.setParameter(RELAY_STATE, relayState);
}
return new HttpGet(builder.build());
} catch (URISyntaxException ex) {
throw new RuntimeException(ex);
}
}

private HttpGet createSamlRedirectMessage(URI samlEndpoint, String relayState, Document samlDoc, String messageType) {
try {
String xml = DocumentUtil.getDocumentAsString(samlDoc);
Expand All @@ -174,7 +201,11 @@ public URI getBindingUri() {
};

public abstract HttpUriRequest createSamlUnsignedRequest(URI samlEndpoint, String relayState, Document samlRequest);

public abstract HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayState, Document samlResponse);

public abstract HttpUriRequest createSamlArtifactRequest(URI samlEndpoint, String relayState, String artifact);

public abstract URI getBindingUri();
}

Expand Down
Loading
Loading
0