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

io.gravitee.am.gateway.handler.oidc.resources.endpoint.UserInfoEndpoint Maven / Gradle / Ivy

There is a newer version: 4.6.0
Show newest version
/**
 * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
 *
 * 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.gravitee.am.gateway.handler.oidc.resources.endpoint;

import io.gravitee.am.common.exception.oauth2.InvalidTokenException;
import io.gravitee.am.common.jwt.JWT;
import io.gravitee.am.common.oidc.CustomClaims;
import io.gravitee.am.common.oidc.Scope;
import io.gravitee.am.common.oidc.StandardClaims;
import io.gravitee.am.common.utils.ConstantKeys;
import io.gravitee.am.gateway.handler.common.jwt.JWTService;
import io.gravitee.am.gateway.handler.common.jwt.SubjectManager;
import io.gravitee.am.gateway.handler.common.utils.Tuple;
import io.gravitee.am.gateway.handler.common.vertx.utils.UriBuilderRequest;
import io.gravitee.am.gateway.handler.oidc.service.discovery.OpenIDDiscoveryService;
import io.gravitee.am.gateway.handler.oidc.service.jwe.JWEService;
import io.gravitee.am.gateway.handler.oidc.service.request.ClaimsRequest;
import io.gravitee.am.model.Role;
import io.gravitee.am.model.User;
import io.gravitee.am.model.oidc.Client;
import io.gravitee.am.service.impl.user.UserEnhancer;
import io.gravitee.common.http.HttpHeaders;
import io.gravitee.common.http.MediaType;
import io.reactivex.rxjava3.core.Single;
import io.vertx.core.Handler;
import io.vertx.core.json.Json;
import io.vertx.rxjava3.ext.web.RoutingContext;
import org.springframework.core.env.Environment;

import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import static io.gravitee.am.common.utils.ConstantKeys.ID_TOKEN_EXCLUDED_CLAIMS;
import static java.util.Optional.ofNullable;

/**
 * The Client sends the UserInfo Request using either HTTP GET or HTTP POST.
 * The Access Token obtained from an OpenID Connect Authentication Request MUST be sent as a Bearer Token, per Section 2 of OAuth 2.0 Bearer Token Usage [RFC6750].
 * It is RECOMMENDED that the request use the HTTP GET method and the Access Token be sent using the Authorization header field.
 *
 * See 5.3.1. UserInfo Request
 *
 * The UserInfo Endpoint is an OAuth 2.0 Protected Resource that returns Claims about the authenticated End-User.
 * To obtain the requested Claims about the End-User, the Client makes a request to the UserInfo Endpoint using an Access Token obtained through OpenID Connect Authentication.
 * These Claims are normally represented by a JSON object that contains a collection of name and value pairs for the Claims.
 *
 * See 5.3. UserInfo Endpoint
 *
 * @author David BRASSELY (david.brassely at graviteesource.com)
 * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
 * @author GraviteeSource Team
 */
public class UserInfoEndpoint implements Handler {

    private final UserEnhancer userEnhancer;
    private final JWTService jwtService;
    private final JWEService jweService;
    private final OpenIDDiscoveryService openIDDiscoveryService;
    private final SubjectManager subjectManager;

    private final boolean legacyOpenidScope;

    public UserInfoEndpoint(UserEnhancer userEnhancer,
                            JWTService jwtService,
                            JWEService jweService,
                            OpenIDDiscoveryService openIDDiscoveryService,
                            Environment environment,
                            SubjectManager subjectManager) {
        this.userEnhancer = userEnhancer;
        this.jwtService = jwtService;
        this.jweService = jweService;
        this.openIDDiscoveryService = openIDDiscoveryService;
        this.legacyOpenidScope = environment.getProperty("legacy.openid.openid_scope_full_profile", boolean.class, false);
        this.subjectManager = subjectManager;
    }

    @Override
    public void handle(RoutingContext context) {
        JWT accessToken = context.get(ConstantKeys.TOKEN_CONTEXT_KEY);
        Client client = context.get(ConstantKeys.CLIENT_CONTEXT_KEY);
        subjectManager.findUserBySub(accessToken)
                .switchIfEmpty(Single.error(() -> new InvalidTokenException("No user found for this token")))
                // enhance user information
                .flatMap(user -> enhance(user, accessToken))
                // process user claims
                .map(user -> Tuple.of(user, processClaims(user, accessToken)))
                // encode response
                .flatMap(tuple -> {
                            final var user = tuple.getT1();
                            final var claims = tuple.getT2();
                            final var jwt = new JWT(claims);
                            subjectManager.updateJWT(jwt, user);
                            if (!expectSignedOrEncryptedUserInfo(client)) {
                                context.response().putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
                                return Single.just(Json.encodePrettily(jwt));
                            } else {
                                context.response().putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JWT);

                                jwt.setIss(openIDDiscoveryService.getIssuer(UriBuilderRequest.resolveProxyRequest(context)));
                                jwt.setAud(accessToken.getAud());
                                jwt.setIat(new Date().getTime() / 1000L);
                                jwt.setExp(accessToken.getExp() / 1000L);

                                return jwtService.encodeUserinfo(jwt, client)//Sign if needed, else return unsigned JWT
                                        .flatMap(userinfo -> jweService.encryptUserinfo(userinfo, client));//Encrypt if needed, else return JWT
                            }
                        }
                )
                .subscribe(
                        buffer -> context.response()
                                .putHeader(HttpHeaders.CACHE_CONTROL, "no-store")
                                .putHeader(HttpHeaders.PRAGMA, "no-cache")
                                .end(buffer)
                        ,
                        context::fail
                );
    }

    /**
     * Process user claims against user data and access token information
     * @param user the end user
     * @param accessToken the access token
     * @return user claims
     */
    private Map processClaims(User user, JWT accessToken) {
        final Map additionalInfos = ofNullable(user.getAdditionalInformation()).orElse(Map.of());
        final Map fullProfileClaims = new HashMap<>(additionalInfos);

        // to be sure that this sub value coming from the IDP will not override the one provided by AM
        // we explicitly remove it from the additional info.
        // see https://github.com/gravitee-io/issues/issues/7118
        fullProfileClaims.remove(StandardClaims.SUB);

        Map userClaims = new HashMap<>();
        // prepare requested claims
        Map requestedClaims = new HashMap<>();

        boolean requestForSpecificClaims = false;
        // processing claims list
        // 1. process the request using scope values
        if (accessToken.getScope() != null) {
            final Set scopes = new HashSet<>(Arrays.asList(accessToken.getScope().split("\\s+")));
            requestForSpecificClaims = processScopesRequest(scopes, userClaims, requestedClaims, fullProfileClaims);
        }
        // 2. process the request using the claims values (If present, the listed Claims are being requested to be added to any Claims that are being requested using scope values.
        // If not present, the Claims being requested from the UserInfo Endpoint are only those requested using scope values.)
        if (accessToken.getClaimsRequestParameter() != null) {
            requestForSpecificClaims = processClaimsRequest((String) accessToken.getClaimsRequestParameter(), fullProfileClaims, requestedClaims);
        }

        // remove technical claims that are useless for the calling app
        ID_TOKEN_EXCLUDED_CLAIMS.forEach(userClaims::remove);

        // Exchange the sub claim from the identity provider to its technical id
        final var sub = subjectManager.generateSubFrom(user);
        userClaims.put(StandardClaims.SUB, sub);
        // SUB claim is required
        requestedClaims.put(StandardClaims.SUB, sub);

        return (requestForSpecificClaims) ? requestedClaims : userClaims;
    }

    /**
     * For OpenID Connect, scopes can be used to request that specific sets of information be made available as Claim Values.
     *
     * @param scopes scopes request parameter
     * @param userClaims requested claims from scope
     * @param requestedClaims requested claims
     * @param fullProfileClaims full profile claims
     * @return true if OpenID Connect scopes have been found
     */
    private boolean processScopesRequest(Set scopes,
                                         Map userClaims,
                                         Map requestedClaims,
                                         final Map fullProfileClaims
    ) {
        // if full_profile requested, continue
        // if legacy mode is enabled, also return all if only openid scope is provided
        if (scopes.contains(Scope.FULL_PROFILE.getKey()) ||
                (legacyOpenidScope && scopes.size() == 1 && scopes.contains(Scope.OPENID.getKey()))) {
            userClaims.putAll(fullProfileClaims);
            return false;
        }

        // get requested scopes claims
        final List scopesClaimKeys = scopes.stream()
                .map(String::toUpperCase)
                .filter(scope -> Scope.exists(scope) && !Scope.valueOf(scope).getClaims().isEmpty())
                .map(Scope::valueOf)
                .map(Scope::getClaims)
                .flatMap(List::stream)
                .toList();

        // no OpenID Connect scopes requested continue
        if (scopesClaimKeys.isEmpty()) {
            return false;
        }

        // return specific available sets of information made by scope value request
        scopesClaimKeys.stream()
                .filter(fullProfileClaims::containsKey)
                .forEach(scopeClaim ->
                        requestedClaims.putIfAbsent(scopeClaim, fullProfileClaims.get(scopeClaim))
                );

        return true;
    }

    /**
     * Handle claims request previously made during the authorization request
     * @param claimsValue claims request parameter
     * @param fullProfileClaims user full claims list
     * @param requestedClaims requested claims
     * @return true if userinfo claims have been found
     */
    private boolean processClaimsRequest(String claimsValue, final Map fullProfileClaims, Map requestedClaims) {
        try {
            ClaimsRequest claimsRequest = Json.decodeValue(claimsValue, ClaimsRequest.class);
            if (claimsRequest != null && claimsRequest.getUserInfoClaims() != null) {
                claimsRequest.getUserInfoClaims().forEach((key, value) -> {
                    if (fullProfileClaims.containsKey(key)) {
                        requestedClaims.putIfAbsent(key, fullProfileClaims.get(key));
                    }
                });
                return true;
            }
        } catch (Exception e) {
            // Any members used that are not understood MUST be ignored.
        }
        return false;
    }

    /**
     * Enhance user information with roles and groups if the access token contains those scopes
     * @param user The end user
     * @param accessToken The access token with required scopes
     * @return enhanced user
     */
    private Single enhance(User user, JWT accessToken) {
        if (!loadRoles(accessToken) && !loadGroups(accessToken)) {
            return Single.just(user);
        }

        return userEnhancer.enhance(user)
                .map(user1 -> {
                    Map userClaims = user.getAdditionalInformation() == null ?
                            new HashMap<>() :
                            new HashMap<>(user.getAdditionalInformation());

                    if (user.getRolesPermissions() != null && !user.getRolesPermissions().isEmpty()) {
                        userClaims.putIfAbsent(CustomClaims.ROLES, user.getRolesPermissions().stream().map(Role::getName).collect(Collectors.toList()));
                    }
                    if (user.getGroups() != null && !user.getGroups().isEmpty()) {
                        userClaims.putIfAbsent(CustomClaims.GROUPS, user.getGroups());
                    }
                    user1.setAdditionalInformation(userClaims);
                    return user1;
                });
    }

    /**
     * @param client Client
     * @return Return true if client request signed or encrypted (or both) userinfo.
     */
    private boolean expectSignedOrEncryptedUserInfo(Client client) {
        return client.getUserinfoSignedResponseAlg() != null || client.getUserinfoEncryptedResponseAlg() != null;
    }

    private boolean loadRoles(JWT accessToken) {
        return accessToken.hasScope(Scope.ROLES.getKey());
    }

    private boolean loadGroups(JWT accessToken) {
        return accessToken.hasScope(Scope.GROUPS.getKey());
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy