DynamicRegistrationService.java

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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.apache.cxf.rs.security.oauth2.services;

import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;

import javax.security.auth.x500.X500Principal;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriBuilder;
import org.apache.cxf.common.util.Base64UrlUtility;
import org.apache.cxf.common.util.Base64Utility;
import org.apache.cxf.common.util.StringUtils;
import org.apache.cxf.jaxrs.ext.MessageContext;
import org.apache.cxf.jaxrs.utils.ExceptionUtils;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.cxf.rs.security.oauth2.common.Client;
import org.apache.cxf.rs.security.oauth2.common.OAuthError;
import org.apache.cxf.rs.security.oauth2.common.UserSubject;
import org.apache.cxf.rs.security.oauth2.provider.AbstractOAuthDataProvider;
import org.apache.cxf.rs.security.oauth2.provider.ClientRegistrationProvider;
import org.apache.cxf.rs.security.oauth2.utils.AuthorizationUtils;
import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants;
import org.apache.cxf.rs.security.oauth2.utils.OAuthUtils;
import org.apache.cxf.rt.security.crypto.CryptoUtils;
import org.apache.cxf.security.transport.TLSSessionInfo;

@Path("register")
public class DynamicRegistrationService {
    private static final List<String> LOOPBACK_HOSTS = List.of("localhost", "127.0.0.1", "[::1]");
    private static final String WEB_APPLICATION_TYPE = "web";
    private static final String NATIVE_APPLICATION_TYPE = "native";
    private static final String INVALID_CLIENT_METADATA = "invalid_client_metadata";
    private static final String DEFAULT_APPLICATION_TYPE = WEB_APPLICATION_TYPE;
    private static final Integer DEFAULT_CLIENT_ID_SIZE = 10;
    private ClientRegistrationProvider clientProvider;
    private String initialAccessToken;
    private int clientIdSizeInBytes = DEFAULT_CLIENT_ID_SIZE;
    private MessageContext mc;
    private boolean supportRegistrationAccessTokens = true;
    private boolean enforceTlsClientAuthCertificateBinding = true;
    private String userRole;
    private List<String> allowedClientScopes;

    @POST
    @Consumes("application/json")
    @Produces("application/json")
    public Response register(ClientRegistration request) {
        checkInitialAuthentication();
        Client client = createNewClient(request);
        createRegAccessToken(client);
        clientProvider.setClient(client);

        return Response.status(201).entity(fromClientToRegistrationResponse(client)).build();
    }

    protected void checkInitialAuthentication() {
        if (initialAccessToken != null) {
            String accessToken = getRequestAccessToken();
            if (!OAuthUtils.compareTokens(initialAccessToken, accessToken)) {
                throw ExceptionUtils.toNotAuthorizedException(null, null);
            }
        } else {
            checkSecurityContext();
        }

    }


    protected void checkSecurityContext() {
        SecurityContext sc = mc.getSecurityContext();
        if (sc.getUserPrincipal() == null) {
            throw ExceptionUtils.toNotAuthorizedException(null, null);
        }
        if (userRole != null && !sc.isUserInRole(userRole)) {
            throw ExceptionUtils.toForbiddenException(null, null);
        }
    }

    protected String createRegAccessToken(Client client) {
        String regAccessToken = OAuthUtils.generateRandomTokenKey();
        client.getProperties().put(ClientRegistrationResponse.REG_ACCESS_TOKEN,
                                   regAccessToken);
        return regAccessToken;
    }
    protected void checkRegistrationAccessToken(Client c, String accessToken) {
        String regAccessToken = c.getProperties().get(ClientRegistrationResponse.REG_ACCESS_TOKEN);

        if (!OAuthUtils.compareTokens(regAccessToken, accessToken)) {
            throw ExceptionUtils.toNotAuthorizedException(null, null);
        }
    }

    @GET
    @Produces("application/json")
    public ClientRegistration readClientRegistrationWithQuery(@QueryParam("client_id") String clientId) {
        return doReadClientRegistration(clientId);
    }

    @GET
    @Path("{clientId}")
    @Produces("application/json")
    public ClientRegistration readClientRegistrationWithPath(@PathParam("clientId") String clientId) {
        return doReadClientRegistration(clientId);
    }

    @PUT
    @Path("{clientId}")
    @Consumes("application/json")
    @Produces("application/json")
    public ClientRegistration updateClientRegistration(@PathParam("clientId") String clientId,
        ClientRegistration request) {
        Client client = readClient(clientId);
        fromClientRegistrationToClient(request, client);
        clientProvider.setClient(client);
        return fromClientToClientRegistration(client);
    }

    @DELETE
    @Path("{clientId}")
    public Response deleteClientRegistration(@PathParam("clientId") String clientId) {
        if (readClient(clientId) != null) {
            clientProvider.removeClient(clientId);
        }

        return Response.ok().build();
    }

    protected ClientRegistrationResponse fromClientToRegistrationResponse(Client client) {
        ClientRegistrationResponse response = new ClientRegistrationResponse();
        response.setClientId(client.getClientId());
        if (client.getClientSecret() != null) {
            response.setClientSecret(client.getClientSecret());
            // TODO: consider making Client secret time limited
            response.setClientSecretExpiresAt(Long.valueOf(0));
        }
        response.setClientIdIssuedAt(client.getRegisteredAt());
        response.setGrantTypes(client.getAllowedGrantTypes());
        UriBuilder ub = getMessageContext().getUriInfo().getAbsolutePathBuilder();

        if (supportRegistrationAccessTokens) {
            // both registration access token and uri are either included or excluded
            response.setRegistrationClientUri(
                ub.path(client.getClientId()).build().toString());

            response.setRegistrationAccessToken(
                client.getProperties().get(ClientRegistrationResponse.REG_ACCESS_TOKEN));
        }
        return response;
    }

    protected ClientRegistration doReadClientRegistration(String clientId) {
        Client client = readClient(clientId);
        return fromClientToClientRegistration(client);
    }

    protected ClientRegistration fromClientToClientRegistration(Client c) {
        ClientRegistration reg = new ClientRegistration();
        reg.setClientName(c.getApplicationName());
        reg.setGrantTypes(c.getAllowedGrantTypes());
        reg.setApplicationType(c.isConfidential() ? WEB_APPLICATION_TYPE : NATIVE_APPLICATION_TYPE);
        if (!c.getRedirectUris().isEmpty()) {
            reg.setRedirectUris(c.getRedirectUris());
        }
        if (!c.getRegisteredScopes().isEmpty()) {
            reg.setScope(OAuthUtils.convertListOfScopesToString(c.getRegisteredScopes()));
        }
        if (c.getApplicationWebUri() != null) {
            reg.setClientUri(c.getApplicationWebUri());
        }
        if (c.getApplicationLogoUri() != null) {
            reg.setLogoUri(c.getApplicationLogoUri());
        }
        if (!c.getRegisteredAudiences().isEmpty()) {
            reg.setResourceUris(c.getRegisteredAudiences());
        }
        if (c.getTokenEndpointAuthMethod() != null) {
            reg.setTokenEndpointAuthMethod(c.getTokenEndpointAuthMethod());
            if (OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS.equals(c.getTokenEndpointAuthMethod())) {
                String subjectDn = c.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN);
                if (subjectDn != null) {
                    reg.setProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, subjectDn);
                }
                String issuerDn = c.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN);
                if (issuerDn != null) {
                    reg.setProperty(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN, issuerDn);
                }
            }
        }

        return reg;
    }

    protected Client readClient(String clientId) {
        String accessToken = getRequestAccessToken();

        Client c = clientProvider.getClient(clientId);
        if (c == null) {
            throw ExceptionUtils.toNotAuthorizedException(null, null);
        }
        checkRegistrationAccessToken(c, accessToken);
        return c;
    }


    public String getInitialAccessToken() {
        return initialAccessToken;
    }

    public void setInitialAccessToken(String initialAccessToken) {
        this.initialAccessToken = initialAccessToken;
    }

    protected Client createNewClient(ClientRegistration request) {
        // Client ID
        String clientId = generateClientId();

        // Client Name
        String clientName = request.getClientName();
        if (StringUtils.isEmpty(clientName)) {
            clientName = clientId;
        }

        List<String> grantTypes = request.getGrantTypes();
        if (grantTypes == null) {
            grantTypes = Collections.singletonList(OAuthConstants.AUTHORIZATION_CODE_GRANT);
        }

        String tokenEndpointAuthMethod = request.getTokenEndpointAuthMethod();
        //TODO: default is expected to be set to OAuthConstants.TOKEN_ENDPOINT_AUTH_BASIC

        boolean passwordRequired = isPasswordRequired(grantTypes, tokenEndpointAuthMethod);

        // Application Type
        // https://tools.ietf.org/html/rfc7591 has no this property but
        // but http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata does
        String appType = request.getApplicationType();
        if (appType == null) {
            appType = DEFAULT_APPLICATION_TYPE;
        }
        boolean isConfidential = DEFAULT_APPLICATION_TYPE.equals(appType)
            && (passwordRequired
                || OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS.equals(tokenEndpointAuthMethod));

        // Client Secret
        String clientSecret = passwordRequired ? generateClientSecret(request) : null;

        Client newClient = new Client(clientId, clientSecret, isConfidential, clientName);

        newClient.setAllowedGrantTypes(grantTypes);

        newClient.setTokenEndpointAuthMethod(tokenEndpointAuthMethod);
        if (OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS.equals(tokenEndpointAuthMethod)) {
            X509Certificate cert = enforceTlsClientAuthCertificateBinding
                ? validateTlsClientAuthCertificateBinding(request) : getTlsClientCertificate();
            String subjectDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN);
            if (subjectDn != null) {
                newClient.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, subjectDn);
            }
            String issuerDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN);
            if (issuerDn != null) {
                newClient.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN, issuerDn);
            }
            if (cert != null) {
                try {
                    newClient.getApplicationCertificates().add(Base64Utility.encode(cert.getEncoded()));
                } catch (Exception ex) {
                    OAuthError error =
                        new OAuthError(INVALID_CLIENT_METADATA, "Unable to register TLS client certificate");
                    reportInvalidRequestError(error);
                }
            }
        }
        // Client Registration Time
        newClient.setRegisteredAt(System.currentTimeMillis() / 1000L);

        fromClientRegistrationToClient(request, newClient);

        SecurityContext sc = mc.getSecurityContext();
        if (sc != null && sc.getUserPrincipal() != null && sc.getUserPrincipal().getName() != null) {
            UserSubject subject = new UserSubject(sc.getUserPrincipal().getName());
            newClient.setResourceOwnerSubject(subject);
        }

        newClient.setRegisteredDynamically(true);
        return newClient;
    }

    protected X509Certificate getTlsClientCertificate() {
        TLSSessionInfo tlsSessionInfo = (TLSSessionInfo)mc.get(TLSSessionInfo.class.getName());
        return tlsSessionInfo == null ? null : OAuthUtils.getRootTLSCertificate(tlsSessionInfo);
    }

    protected X509Certificate validateTlsClientAuthCertificateBinding(ClientRegistration request) {
        X509Certificate cert = getTlsClientCertificate();
        if (cert == null) {
            OAuthError error =
                new OAuthError(INVALID_CLIENT_METADATA, "TLS client certificate is required");
            reportInvalidRequestError(error);
        }

        String subjectDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN);
        if (subjectDn != null
            && !isSameDistinguishedName(subjectDn, OAuthUtils.getSubjectDnFromTLSCertificates(cert))) {
            OAuthError error =
                new OAuthError(INVALID_CLIENT_METADATA, "Invalid tls_client_auth_subject_dn metadata");
            reportInvalidRequestError(error);
        }
        String issuerDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN);
        if (issuerDn != null
            && !isSameDistinguishedName(issuerDn, OAuthUtils.getIssuerDnFromTLSCertificates(cert))) {
            OAuthError error =
                new OAuthError(INVALID_CLIENT_METADATA, "Invalid tls_client_auth_root_dn metadata");
            reportInvalidRequestError(error);
        }
        return cert;
    }

    private boolean isSameDistinguishedName(String expectedDn, String actualDn) {
        try {
            return new X500Principal(expectedDn).equals(new X500Principal(actualDn));
        } catch (IllegalArgumentException ex) {
            return false;
        }
    }

    protected void fromClientRegistrationToClient(ClientRegistration request, Client client) {
        final List<String> grantTypes = client.getAllowedGrantTypes();

        // Client Redirect URIs
        List<String> redirectUris = request.getRedirectUris();
        if (redirectUris != null) {
            String appType = request.getApplicationType();
            if (appType == null) {
                appType = DEFAULT_APPLICATION_TYPE;
            }
            for (String uri : redirectUris) {
                validateRequestUri(uri, appType, grantTypes);
            }
            client.setRedirectUris(redirectUris);
        }

        if (client.getRedirectUris().isEmpty()
            && (grantTypes.contains(OAuthConstants.AUTHORIZATION_CODE_GRANT)
                || grantTypes.contains(OAuthConstants.IMPLICIT_GRANT))) {
            // Throw an error as we need a redirect URI for these grants.
            OAuthError error =
                new OAuthError(OAuthConstants.INVALID_REQUEST, "A Redirection URI is required");
            reportInvalidRequestError(error);
        }

        // Client Resource Audience URIs
        List<String> resourceUris = request.getResourceUris();
        if (resourceUris != null) {
            client.setRegisteredAudiences(resourceUris);
        }

        // Client Scopes
        String scope = request.getScope();
        if (!StringUtils.isEmpty(scope)) {
            List<String> requestedScopes = OAuthUtils.parseScope(scope);
            validateClientScopes(requestedScopes);
            client.setRegisteredScopes(requestedScopes);
        }
        // Client Application URI
        String clientUri = request.getClientUri();
        if (clientUri != null) {
            client.setApplicationWebUri(clientUri);
        }
        // Client Logo URI
        String clientLogoUri = request.getLogoUri();
        if (clientLogoUri != null) {
            client.setApplicationLogoUri(clientLogoUri);
        }

        //TODO: check other properties
        // Add more typed properties like tosUri, policyUri, etc to Client
        // or set them as Client extra properties
    }


    protected boolean isPasswordRequired(List<String> grantTypes, String tokenEndpointAuthMethod) {
        if (grantTypes.contains(OAuthConstants.IMPLICIT_GRANT)) {
            return false;
        }
        if (tokenEndpointAuthMethod == null) {
            return true;
        }

        return !OAuthConstants.TOKEN_ENDPOINT_AUTH_NONE.equals(tokenEndpointAuthMethod)
            && (OAuthConstants.TOKEN_ENDPOINT_AUTH_BASIC.equals(tokenEndpointAuthMethod)
                || OAuthConstants.TOKEN_ENDPOINT_AUTH_POST.equals(tokenEndpointAuthMethod));
    }

    @SuppressWarnings("PMD.CollapsibleIfStatements")
    protected void validateRequestUri(String uri, String appType, List<String> grantTypes) {
        if (uri == null || uri.isBlank()) {
            reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA, "Empty redirect URI is not supported"));
        }

        // Web Clients using the OAuth Implicit Grant Type MUST only register URLs using the https scheme
        // as redirect_uris; they MUST NOT use localhost as the hostname. Native Clients MUST only register
        // redirect_uris using custom URI schemes or loopback URLs using the http scheme; loopback URLs use 
        // localhost or the IP loopback literals 127.0.0.1 or [::1] as the hostname.
        // Authorization Servers MAY place additional constraints on Native Clients. Authorization Servers MAY
        // reject Redirection URI values using the http scheme, other than the localhost case for Native Clients

        final URI parsedUri = URI.create(uri);
        if (parsedUri.getHost() == null || parsedUri.getScheme() == null) {
            reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA, "Unsupported redirect URI scheme/host"));
        }
        final String host = parsedUri.getHost().toLowerCase();
        final String scheme = parsedUri.getScheme().toLowerCase();
        if (!scheme.equals("http") && !scheme.equals("https")) {
            reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA,
                "Redirect URI scheme is not allowed: " + scheme
                    + ". Allowed schemes: http, https"));
        }

        // Kind of the application. The default, if omitted, is web. The defined values are native or web.
        if (appType == null || appType.isBlank() || appType.equalsIgnoreCase(WEB_APPLICATION_TYPE)) {
            if (grantTypes.contains(OAuthConstants.IMPLICIT_GRANT)) {
                if (!"https".equals(scheme)) {
                    reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA,
                        "Unsupported redirect URI scheme"));
                } else if ("localhost".equals(host)) {
                    reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA,
                        "Unsupported redirect URI hostname"));
                }
            }
        } else if (appType.equalsIgnoreCase(NATIVE_APPLICATION_TYPE)) {
            if ("http".equals(scheme) && !LOOPBACK_HOSTS.contains(host)) {
                reportInvalidRequestError(new OAuthError(INVALID_CLIENT_METADATA,
                    "Unsupported redirect URI hostname for scheme"));
            }
        }
    }

    public void setClientProvider(ClientRegistrationProvider clientProvider) {
        this.clientProvider = clientProvider;
    }

    protected String generateClientId() {
        return Base64UrlUtility.encode(
                   CryptoUtils.generateSecureRandomBytes(
                        getClientIdSizeInBytes()));
    }

    public int getClientIdSizeInBytes() {
        return clientIdSizeInBytes;
    }
    public void setClientIdSizeInBytes(int size) {
        clientIdSizeInBytes = size;
    }

    protected String generateClientSecret(ClientRegistration request) {
        return Base64UrlUtility.encode(
                   CryptoUtils.generateSecureRandomBytes(
                       getClientSecretSizeInBytes(request)));
    }

    protected String getRequestAccessToken() {
        // This call will throw 401 if no given authorization scheme exists
        return AuthorizationUtils.getAuthorizationParts(getMessageContext(),
                    Collections.singleton(OAuthConstants.BEARER_AUTHORIZATION_SCHEME))[1];
    }
    protected int getClientSecretSizeInBytes(ClientRegistration request) {
        return 32;
    }

    @Context
    public void setMessageContext(MessageContext context) {
        this.mc = context;
    }

    public MessageContext getMessageContext() {
        return mc;
    }

    public void setSupportRegistrationAccessTokens(boolean supportRegistrationAccessTokens) {
        this.supportRegistrationAccessTokens = supportRegistrationAccessTokens;
    }

    public void setEnforceTlsClientAuthCertificateBinding(boolean enforceTlsClientAuthCertificateBinding) {
        this.enforceTlsClientAuthCertificateBinding = enforceTlsClientAuthCertificateBinding;
    }

    public void setUserRole(String userRole) {
        this.userRole = userRole;
    }

    public void setAllowedClientScopes(List<String> allowedClientScopes) {
        this.allowedClientScopes = allowedClientScopes;
    }

    protected void validateClientScopes(List<String> requestedScopes) {
        if (requestedScopes == null || requestedScopes.isEmpty()) {
            return;
        }

        List<String> allowedScopes = allowedClientScopes;
        if (allowedScopes == null && clientProvider instanceof AbstractOAuthDataProvider) {
            allowedScopes = new java.util.ArrayList<>(
                ((AbstractOAuthDataProvider)clientProvider).getPermissionMap().keySet());
        }

        if (allowedScopes != null && !new HashSet<>(allowedScopes).containsAll(requestedScopes)) {
            OAuthError error =
                new OAuthError(INVALID_CLIENT_METADATA, "Invalid scope metadata");
            reportInvalidRequestError(error);
        }
    }

    private void reportInvalidRequestError(OAuthError entity) {
        reportInvalidRequestError(entity, MediaType.APPLICATION_JSON_TYPE);
    }

    private void reportInvalidRequestError(OAuthError entity, MediaType mt) {
        ResponseBuilder rb = JAXRSUtils.toResponseBuilder(400);
        if (mt != null) {
            rb.type(mt);
        }
        throw ExceptionUtils.toBadRequestException(null, rb.entity(entity).build());
    }
}