All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.helidon.security.providers.idcs.mapper.IdcsRoleMapperProviderBase Maven / Gradle / Ivy

There is a newer version: 4.1.4
Show newest version
/*
 * Copyright (c) 2021, 2024 Oracle and/or its affiliates.
 *
 * 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 io.helidon.security.providers.idcs.mapper;

import java.lang.System.Logger.Level;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

import io.helidon.common.LazyValue;
import io.helidon.common.config.Config;
import io.helidon.common.context.Context;
import io.helidon.common.context.Contexts;
import io.helidon.common.parameters.Parameters;
import io.helidon.config.metadata.Configured;
import io.helidon.config.metadata.ConfiguredOption;
import io.helidon.http.HeaderValues;
import io.helidon.http.Status;
import io.helidon.security.AuthenticationResponse;
import io.helidon.security.Grant;
import io.helidon.security.ProviderRequest;
import io.helidon.security.Role;
import io.helidon.security.Subject;
import io.helidon.security.SubjectType;
import io.helidon.security.integration.common.RoleMapTracing;
import io.helidon.security.jwt.Jwt;
import io.helidon.security.jwt.JwtValidator;
import io.helidon.security.jwt.SignedJwt;
import io.helidon.security.providers.oidc.common.OidcConfig;
import io.helidon.security.spi.SubjectMappingProvider;
import io.helidon.webclient.api.HttpClientRequest;
import io.helidon.webclient.api.HttpClientResponse;
import io.helidon.webclient.api.WebClient;

import jakarta.json.JsonArray;
import jakarta.json.JsonObject;

/**
 * Common functionality for IDCS role mapping using {@link io.helidon.webclient.http1.Http1Client}.
 */
public abstract class IdcsRoleMapperProviderBase implements SubjectMappingProvider {
    /**
     * User subject type used when requesting roles from IDCS.
     * An attempt is made to obtain it from JWT claim {@code sub_type}. If not defined,
     * default is used as configured in {@link IdcsRoleMapperProviderBase.Builder}.
     */
    public static final String IDCS_SUBJECT_TYPE_USER = "user";
    /**
     * Client subject type used when requesting roles from IDCS.
     * An attempt is made to obtain it from JWT claim {@code sub_type}. If not defined,
     * default is used as configured in {@link IdcsRoleMapperProviderBase.Builder}.
     */
    public static final String IDCS_SUBJECT_TYPE_CLIENT = "client";
    /**
     * Json key for group roles to be retrieved from IDCS response.
     */
    protected static final String ROLE_GROUP = "groups";
    /**
     * Json key for app roles to be retrieved from IDCS response.
     */
    protected static final String ROLE_APPROLE = "appRoles";
    /**
     * Json key for token to be retrieved from IDCS response when requesting application token.
     */
    protected static final String ACCESS_TOKEN_KEY = "access_token";
    /**
     * Property sent with JAX-RS requests to override parent span context in outbound calls.
     * We cannot use the constant declared in {@code ClientTracingFilter}, as it is not a required dependency.
     */
    protected static final String PARENT_CONTEXT_CLIENT_PROPERTY = "io.helidon.tracing.span-context";
    private static final System.Logger LOGGER = System.getLogger(IdcsRoleMapperProviderBase.class.getName());

    private final Set supportedTypes = EnumSet.noneOf(SubjectType.class);
    private final OidcConfig oidcConfig;
    private final String defaultIdcsSubjectType;

    /**
     * Configures the needed fields from the provided builder.
     *
     * @param builder builder with oidcConfig and other needed fields.
     */
    protected IdcsRoleMapperProviderBase(Builder builder) {
        this.oidcConfig = builder.oidcConfig;
        this.oidcConfig.tokenEndpointUri(); //Remove once IDCS is rewritten to be lazily loaded
        this.defaultIdcsSubjectType = builder.defaultIdcsSubjectType;
        if (builder.supportedTypes.isEmpty()) {
            this.supportedTypes.add(SubjectType.USER);
        } else {
            this.supportedTypes.addAll(builder.supportedTypes);
        }
    }

    @Override
    public AuthenticationResponse map(ProviderRequest authenticatedRequest, AuthenticationResponse previousResponse) {

        Optional maybeUser = previousResponse.user();
        Optional maybeService = previousResponse.service();

        if (maybeService.isEmpty() && maybeUser.isEmpty()) {
            return previousResponse;
        }

        // create a new response
        AuthenticationResponse.Builder builder = AuthenticationResponse.builder()
                .requestHeaders(previousResponse.requestHeaders())
                .responseHeaders(previousResponse.responseHeaders());
        previousResponse.description().ifPresent(builder::description);

        if (maybeUser.isPresent()) {
            if (supportedTypes.contains(SubjectType.USER)) {
                Subject subject = enhance(authenticatedRequest, previousResponse, maybeUser.get());
                builder.user(subject);
            } else {
                builder.service(maybeUser.get());
            }
        }

        if (maybeService.isPresent()) {
            if (supportedTypes.contains(SubjectType.SERVICE)) {
                Subject subject = enhance(authenticatedRequest, previousResponse, maybeService.get());
                builder.user(subject);
            } else {
                builder.service(maybeService.get());
            }
        }

        return builder.build();
    }

    /**
     * Enhance subject with IDCS roles, reactive.
     *
     * @param request provider request
     * @param previousResponse authenticated response
     * @param subject subject to enhance
     * @return future with enhanced subject
     */
    protected abstract Subject enhance(ProviderRequest request, AuthenticationResponse previousResponse, Subject subject);

    /**
     * Updates original subject with the list of grants.
     *
     * @param originalSubject as was created by authentication provider
     * @param grants          grants added by this role mapper
     * @return new subject
     */
    protected Subject buildSubject(Subject originalSubject, List grants) {
        Subject.Builder builder = Subject.builder();
        builder.update(originalSubject);

        grants.forEach(builder::addGrant);

        return builder.build();
    }

    protected List processRoleRequest(HttpClientRequest request, Object entity, String subjectName) {
        try (HttpClientResponse response = request.submit(entity)) {
            if (response.status().family() == Status.Family.SUCCESSFUL) {
                try {
                    JsonObject jsonObject = response.as(JsonObject.class);
                    return processServerResponse(jsonObject, subjectName);
                } catch (Exception e) {
                    LOGGER.log(Level.WARNING,
                               "Cannot read groups for user \"" + subjectName + "\". "
                                       + "Error message: Failed to read JSON from response",
                               e);
                }
            } else {
                String message;
                try {
                    message = response.as(String.class);
                    LOGGER.log(Level.WARNING, "Cannot read groups for user \"" + subjectName + "\". "
                            + "Response code: " + response.status()
                            + (response.status() == Status.UNAUTHORIZED_401 ? ", make sure your IDCS client has role "
                                    + "\"Authenticator Client\" added on the client configuration page" : "")
                            + ", error entity: " + message);
                } catch (Exception e) {
                    LOGGER.log(Level.WARNING,
                               "Cannot read groups for user \"" + subjectName + "\". "
                                       + "Error message: Failed to process error entity",
                               e);
                }
            }
        } catch (Exception e) {
            LOGGER.log(Level.WARNING, "Cannot read groups for user \"" + subjectName + "\". "
                               + "Error message: Failed to invoke request",
                       e);
        }
        return List.of();
    }

    /**
     * Access to {@link io.helidon.security.providers.oidc.common.OidcConfig} so the field is not duplicated by
     *  classes that extend this provider.
     *
     * @return open ID Connect configuration (also used to configure access to IDCS)
     */
    protected OidcConfig oidcConfig() {
        return oidcConfig;
    }

    /**
     * Default subject type to use when requesting data from IDCS.
     *
     * @return configured default subject type or {@link #IDCS_SUBJECT_TYPE_USER}
     */
    protected String defaultIdcsSubjectType() {
        return defaultIdcsSubjectType;
    }

    private List processServerResponse(JsonObject jsonObject, String subjectName) {
        JsonArray groups = jsonObject.getJsonArray("groups");
        JsonArray appRoles = jsonObject.getJsonArray("appRoles");

        if ((null == groups) && (null == appRoles)) {
            LOGGER.log(Level.TRACE, () -> "Neither groups nor app roles found for user " + subjectName);
            return List.of();
        }

        List result = new ArrayList<>();
        for (String type : Arrays.asList(ROLE_GROUP, ROLE_APPROLE)) {
            JsonArray types = jsonObject.getJsonArray(type);
            if (null != types) {
                for (int i = 0; i < types.size(); i++) {
                    JsonObject typeJson = types.getJsonObject(i);
                    String name = typeJson.getString("display");
                    String id = typeJson.getString("value");
                    String ref = typeJson.getString("$ref");

                    Role role = Role.builder()
                            .name(name)
                            .addAttribute("type", type)
                            .addAttribute("id", id)
                            .addAttribute("ref", ref)
                            .build();

                    result.add(role);
                }
            }
        }

        return result;
    }

    /**
     * Fluent API builder for {@link IdcsRoleMapperProviderBase}.
     * @param  Type of the extending builder
     */
    @Configured
    public static class Builder> {

        private final Set supportedTypes = EnumSet.noneOf(SubjectType.class);
        @SuppressWarnings("unchecked")
        private final B me = (B) this;
        private String defaultIdcsSubjectType = IDCS_SUBJECT_TYPE_USER;
        private OidcConfig oidcConfig;

        /**
         * Default constructor.
         */
        protected Builder() {
        }

        /**
         * Update this builder state from configuration.
         * Expects:
         * 
    *
  • oidc-config to load an instance of {@link io.helidon.security.providers.oidc.common.OidcConfig}
  • *
  • cache-config (optional) to load instances of {@link io.helidon.security.providers.common.EvictableCache} for * caching
  • *
  • default-idcs-subject-type to use when not defined in a JWT, either {@value #IDCS_SUBJECT_TYPE_USER} or * {@link #IDCS_SUBJECT_TYPE_CLIENT}, defaults to {@value #IDCS_SUBJECT_TYPE_USER}
  • *
* * @param config current node must have "oidc-config" as one of its children * @return updated builder instance */ public B config(Config config) { config.get("oidc-config").asNode().ifPresent(it -> { OidcConfig.Builder builder = OidcConfig.builder(); // we do not need JWT validation at all builder.validateJwtWithJwk(false); // this is an IDCS specific extension builder.serverType("idcs"); builder.config(it); oidcConfig(builder.build()); }); config.get("subject-types").mapList(cfg -> cfg.asString().map(SubjectType::valueOf).get()) .ifPresent(list -> list.forEach(this::addSubjectType)); config.get("default-idcs-subject-type").asString().ifPresent(this::defaultIdcsSubjectType); return me; } /** * Use explicit {@link io.helidon.security.providers.oidc.common.OidcConfig} instance, e.g. when using it also for OIDC * provider. * * @param config oidc specific configuration, must have at least identity endpoint and client credentials configured * @return updated builder instance */ @ConfiguredOption public B oidcConfig(OidcConfig config) { this.oidcConfig = config; return me; } /** * Get the configuration to access IDCS instance. * @return oidc config */ protected OidcConfig oidcConfig() { return oidcConfig; } /** * Configure supported subject types. * By default {@link io.helidon.security.SubjectType#USER} is used if none configured. * * @param types types to configure as supported for mapping * @return updated builder instance */ public B subjectTypes(SubjectType... types) { this.supportedTypes.clear(); this.supportedTypes.addAll(Arrays.asList(types)); return me; } /** * Configure subject type to use when requesting roles from IDCS. * Can be either {@link #IDCS_SUBJECT_TYPE_USER} or {@link #IDCS_SUBJECT_TYPE_CLIENT}. * Defaults to {@link #IDCS_SUBJECT_TYPE_USER}. * * @param subjectType type of subject to use when requesting roles from IDCS * @return updated builder instance */ @ConfiguredOption(IDCS_SUBJECT_TYPE_USER) public B defaultIdcsSubjectType(String subjectType) { this.defaultIdcsSubjectType = subjectType; return me; } /** * Add a supported subject type. * If none added, {@link io.helidon.security.SubjectType#USER} is used. * If any added, only the ones added will be used (e.g. if you want to use * both {@link io.helidon.security.SubjectType#USER} and {@link io.helidon.security.SubjectType#SERVICE}, * both need to be added. * * @param type subject type to add to the list of supported types * @return updated builder instance */ @ConfiguredOption(key = "subject-types", kind = ConfiguredOption.Kind.LIST, value = "USER") public B addSubjectType(SubjectType type) { this.supportedTypes.add(type); return me; } } /** * Reactive token for app access to IDCS. */ protected static class AppToken { private static final JwtValidator TIME_VALIDATORS = JwtValidator.builder() .addDefaultTimeValidators() .build(); private final AtomicReference> token = new AtomicReference<>(); private final WebClient webClient; private final URI tokenEndpointUri; private final Duration tokenRefreshSkew; protected AppToken(WebClient webClient, URI tokenEndpointUri, Duration tokenRefreshSkew) { this.webClient = webClient; this.tokenEndpointUri = tokenEndpointUri; this.tokenRefreshSkew = tokenRefreshSkew; } protected Optional getToken(RoleMapTracing tracing) { LazyValue currentTokenData = token.get(); if (currentTokenData == null) { LazyValue newLazyValue = LazyValue.create(() -> fromServer(tracing)); if (token.compareAndSet(null, newLazyValue)) { currentTokenData = newLazyValue; } else { // another thread "stole" the data, return its future currentTokenData = token.get(); } return currentTokenData.get().tokenContent(); } AppTokenData tokenData = currentTokenData.get(); Jwt jwt = tokenData.appJwt(); if (jwt == null || !TIME_VALIDATORS.validate(jwt).isValid() || isNearExpiration(jwt)) { // it is not valid or is very close to expiration - we must get a new value LazyValue newLazyValue = LazyValue.create(() -> fromServer(tracing)); if (token.compareAndSet(currentTokenData, newLazyValue)) { currentTokenData = newLazyValue; } else { // another thread "stole" the data, return its future currentTokenData = token.get(); } return currentTokenData.get().tokenContent(); } else { // present and valid return tokenData.tokenContent(); } } private boolean isNearExpiration(Jwt jwt) { return jwt.expirationTime() .map(exp -> exp.minus(tokenRefreshSkew).isBefore(Instant.now())) .orElse(false); } private AppTokenData fromServer(RoleMapTracing tracing) { Parameters params = Parameters.builder("idcs-form-params") .add("grant_type", "client_credentials") .add("scope", "urn:opc:idm:__myscopes__") .build(); // use current span context as a parent for client outbound // using a custom child context, so we do not replace the parent in the current context Context parentContext = Contexts.context().orElseGet(Contexts::globalContext); Context childContext = Context.builder() .parent(parentContext) .build(); tracing.findParent() .ifPresent(childContext::register); HttpClientRequest request = webClient.post() .uri(tokenEndpointUri) .header(HeaderValues.ACCEPT_JSON); try (HttpClientResponse response = request.submit(params)) { if (response.status().family() == Status.Family.SUCCESSFUL) { try { JsonObject jsonObject = response.as(JsonObject.class); String accessToken = jsonObject.getString(ACCESS_TOKEN_KEY); LOGGER.log(Level.TRACE, () -> "Access token: " + accessToken); SignedJwt signedJwt = SignedJwt.parseToken(accessToken); return new AppTokenData(accessToken, signedJwt.getJwt()); } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to obtain access token for application to read " + "groups from IDCS. Failed with exception: Failed to read JSON from response", e); } } else { String message; try { message = response.as(String.class); LOGGER.log(Level.WARNING, "Failed to obtain access token for application to read " + "groups from IDCS. Status: " + response.status() + ", error message: " + message); } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to obtain access token for application to read " + "groups from IDCS. Failed with exception: Failed to process error entity", e); } } } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to obtain access token for application to read " + "groups from IDCS. Failed with exception: Failed to invoke request", e); } return new AppTokenData(); } } private static final class AppTokenData { private final Optional tokenContent; private final Jwt appJwt; AppTokenData() { this.tokenContent = Optional.empty(); this.appJwt = null; } AppTokenData(String tokenContent, Jwt appJwt) { this.tokenContent = Optional.ofNullable(tokenContent); this.appJwt = appJwt; } Optional tokenContent() { return tokenContent; } Jwt appJwt() { return appJwt; } } }