HostnameV2Provider.java

/*
 * Copyright 2024 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.keycloak.url;

import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.KeycloakSession;
import org.keycloak.urls.HostnameProvider;
import org.keycloak.urls.UrlType;

import java.net.URI;
import java.util.Optional;

import static org.keycloak.common.util.UriUtils.checkUrl;
import static org.keycloak.urls.UrlType.FRONTEND;
import static org.keycloak.utils.StringUtil.isNotBlank;

/**
 * @author Vaclav Muzikar <vmuzikar@redhat.com>
 */
public class HostnameV2Provider implements HostnameProvider {
    private final KeycloakSession session;
    private final String hostname;
    private final URI hostnameUrl;
    private final URI adminUrl;
    private final Boolean backchannelDynamic;
    private static final UrlType defaultUrlType = FRONTEND;

    private final Logger logger = Logger.getLogger(HostnameV2Provider.class);

    public HostnameV2Provider(KeycloakSession session, String hostname, URI hostnameUrl, URI adminUrl, Boolean backchannelDynamic) {
        this.session = session;
        this.hostname = hostname;
        this.hostnameUrl = hostnameUrl;
        this.adminUrl = adminUrl;
        this.backchannelDynamic = backchannelDynamic;
    }

    private URI getUri(UriInfo originalUriInfo, UrlType type) {
        UriBuilder builder;

        switch (type) {
            case ADMIN:
                builder = getAdminUriBuilder(originalUriInfo);
                break;
            case LOCAL_ADMIN:
                builder = originalUriInfo.getBaseUriBuilder();
                // This might not be enough if a reverse proxy is used. In that case we might e.g. have wrong local ports in originalUriInfo.
                // However, that would be transparent to us (we don't know the actual server ports in this context AFAIK).
                builder.host("localhost");
                break;
            case BACKEND:
                builder = backchannelDynamic ? originalUriInfo.getBaseUriBuilder() : getFrontUriBuilder(originalUriInfo);
                break;
            case FRONTEND:
                builder = getFrontUriBuilder(originalUriInfo);
                break;
            default:
                throw new IllegalArgumentException("Unknown URL type");
        }

        // sanitize ports
        URI uriPeak = builder.build();
        if ((uriPeak.getScheme().equals("http") && uriPeak.getPort() == 80) || (uriPeak.getScheme().equals("https") && uriPeak.getPort() == 443)) {
            builder.port(-1);
        }

        return builder.build();
    }

    private UriBuilder getFrontUriBuilder(UriInfo originalUriInfo) {
        UriBuilder builder = getRealmFrontUriBuilder();

        if (builder != null) {
            return builder;
        }

        if (hostnameUrl != null) {
            builder = UriBuilder.fromUri(hostnameUrl);
        }
        else {
            builder = originalUriInfo.getBaseUriBuilder();
            if (hostname != null) {
                builder.host(hostname);
            }
        }
        return builder;
    }

    private UriBuilder getRealmFrontUriBuilder() {
        return Optional.ofNullable(session)
                .map(s -> s.getContext())
                .map(c -> c.getRealm())
                .map(r -> r.getAttribute("frontendUrl"))
                .filter(url -> isNotBlank(url))
                .filter(url -> {
                    try {
                        // this check is aligned with other Hostname providers to avoid breaking changes; note that checking URL this way is considered insufficient, see e.g. https://stackoverflow.com/a/5965755
                        checkUrl(SslRequired.NONE, url, "Realm frontendUrl");
                    }
                    catch (IllegalArgumentException e) {
                        logger.errorf(e, "Failed to parse realm frontendUrl '%s'. Falling back to global value.", url);
                        return false;
                    }
                    return true;
                })
                .map(UriBuilder::fromUri)
                .orElse(null);
    }

    private UriBuilder getAdminUriBuilder(UriInfo originalUriInfo) {
        return adminUrl != null ? UriBuilder.fromUri(adminUrl) : getFrontUriBuilder(originalUriInfo);
    }

    @Override
    public String getScheme(UriInfo originalUriInfo, UrlType type) {
        return getUri(originalUriInfo, type).getScheme();
    }

    @Override
    public String getScheme(UriInfo originalUriInfo) {
        return getScheme(originalUriInfo, defaultUrlType);
    }

    @Override
    public String getHostname(UriInfo originalUriInfo, UrlType type) {
        return getUri(originalUriInfo, type).getHost();
    }

    @Override
    public String getHostname(UriInfo originalUriInfo) {
        return getHostname(originalUriInfo, defaultUrlType);
    }

    @Override
    public int getPort(UriInfo originalUriInfo, UrlType type) {
        return getUri(originalUriInfo, type).getPort();
    }

    @Override
    public int getPort(UriInfo originalUriInfo) {
        return getPort(originalUriInfo, defaultUrlType);
    }

    @Override
    public String getContextPath(UriInfo originalUriInfo, UrlType type) {
        return getUri(originalUriInfo, type).getPath();
    }

    @Override
    public String getContextPath(UriInfo originalUriInfo) {
        return getContextPath(originalUriInfo, defaultUrlType);
    }
}