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

org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectRealm Maven / Gradle / Ivy

There is a newer version: 8.16.1
Show newest version
/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0; you may not use this file except in compliance with the Elastic License
 * 2.0.
 */
package org.elasticsearch.xpack.security.authc.oidc;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.ResponseType;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
import com.nimbusds.oauth2.sdk.id.ClientID;
import com.nimbusds.oauth2.sdk.id.Issuer;
import com.nimbusds.oauth2.sdk.id.State;
import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
import com.nimbusds.openid.connect.sdk.LogoutRequest;
import com.nimbusds.openid.connect.sdk.Nonce;

import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.settings.SettingsException;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.Releasable;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.watcher.ResourceWatcherService;
import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse;
import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationResponse;
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.RealmSettings;
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.core.ssl.SSLService;
import org.elasticsearch.xpack.security.authc.TokenService;
import org.elasticsearch.xpack.security.authc.support.ClaimParser;
import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.DN_CLAIM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.GROUPS_CLAIM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.MAIL_CLAIM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.NAME_CLAIM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_ENDSESSION_ENDPOINT;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_ISSUER;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_JWKSET_PATH;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_TOKEN_ENDPOINT;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_USERINFO_ENDPOINT;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.POPULATE_USER_METADATA;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.PRINCIPAL_CLAIM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_AUTH_JWT_SIGNATURE_ALGORITHM;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_AUTH_METHOD;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_ID;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_SECRET;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_POST_LOGOUT_REDIRECT_URI;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REDIRECT_URI;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REQUESTED_SCOPES;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_RESPONSE_TYPE;
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_SIGNATURE_ALGORITHM;

public class OpenIdConnectRealm extends Realm implements Releasable {

    public static final String CONTEXT_TOKEN_DATA = "_oidc_tokendata";
    private final OpenIdConnectProviderConfiguration opConfiguration;
    private final RelyingPartyConfiguration rpConfiguration;
    private final OpenIdConnectAuthenticator openIdConnectAuthenticator;
    private final ClaimParser principalAttribute;
    private final ClaimParser groupsAttribute;
    private final ClaimParser dnAttribute;
    private final ClaimParser nameAttribute;
    private final ClaimParser mailAttribute;
    private final Boolean populateUserMetadata;
    private final UserRoleMapper roleMapper;

    private DelegatedAuthorizationSupport delegatedRealms;

    public OpenIdConnectRealm(RealmConfig config, SSLService sslService, UserRoleMapper roleMapper, ResourceWatcherService watcherService) {
        super(config);
        this.roleMapper = roleMapper;
        this.rpConfiguration = buildRelyingPartyConfiguration(config);
        this.opConfiguration = buildOpenIdConnectProviderConfiguration(config);
        this.principalAttribute = ClaimParser.forSetting(logger, PRINCIPAL_CLAIM, config, true);
        this.groupsAttribute = ClaimParser.forSetting(logger, GROUPS_CLAIM, config, false);
        this.dnAttribute = ClaimParser.forSetting(logger, DN_CLAIM, config, false);
        this.nameAttribute = ClaimParser.forSetting(logger, NAME_CLAIM, config, false);
        this.mailAttribute = ClaimParser.forSetting(logger, MAIL_CLAIM, config, false);
        this.populateUserMetadata = config.getSetting(POPULATE_USER_METADATA);
        if (TokenService.isTokenServiceEnabled(config.settings()) == false) {
            throw new IllegalStateException(
                "OpenID Connect Realm requires that the token service be enabled ("
                    + XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey()
                    + ")"
            );
        }
        this.openIdConnectAuthenticator = new OpenIdConnectAuthenticator(
            config,
            opConfiguration,
            rpConfiguration,
            sslService,
            watcherService
        );
    }

    // For testing
    OpenIdConnectRealm(RealmConfig config, OpenIdConnectAuthenticator authenticator, UserRoleMapper roleMapper) {
        super(config);
        this.roleMapper = roleMapper;
        this.rpConfiguration = buildRelyingPartyConfiguration(config);
        this.opConfiguration = buildOpenIdConnectProviderConfiguration(config);
        this.openIdConnectAuthenticator = authenticator;
        this.principalAttribute = ClaimParser.forSetting(logger, PRINCIPAL_CLAIM, config, true);
        this.groupsAttribute = ClaimParser.forSetting(logger, GROUPS_CLAIM, config, false);
        this.dnAttribute = ClaimParser.forSetting(logger, DN_CLAIM, config, false);
        this.nameAttribute = ClaimParser.forSetting(logger, NAME_CLAIM, config, false);
        this.mailAttribute = ClaimParser.forSetting(logger, MAIL_CLAIM, config, false);
        this.populateUserMetadata = config.getSetting(POPULATE_USER_METADATA);
    }

    @Override
    public void initialize(Iterable realms, XPackLicenseState licenseState) {
        if (delegatedRealms != null) {
            throw new IllegalStateException("Realm has already been initialized");
        }
        delegatedRealms = new DelegatedAuthorizationSupport(realms, config, licenseState);
    }

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof OpenIdConnectToken;
    }

    private boolean isTokenForRealm(OpenIdConnectToken oidcToken) {
        if (oidcToken.getAuthenticatingRealm() == null) {
            return true;
        } else {
            return oidcToken.getAuthenticatingRealm().equals(this.name());
        }
    }

    @Override
    public AuthenticationToken token(ThreadContext context) {
        return null;
    }

    @Override
    public void authenticate(AuthenticationToken token, ActionListener> listener) {
        if (token instanceof OpenIdConnectToken oidcToken && isTokenForRealm(oidcToken)) {
            openIdConnectAuthenticator.authenticate(
                oidcToken,
                ActionListener.wrap(jwtClaimsSet -> { buildUserFromClaims(jwtClaimsSet, listener); }, e -> {
                    logger.debug("Failed to consume the OpenIdConnectToken ", e);
                    if (e instanceof ElasticsearchSecurityException) {
                        listener.onResponse(AuthenticationResult.unsuccessful("Failed to authenticate user with OpenID Connect", e));
                    } else {
                        listener.onFailure(e);
                    }
                })
            );
        } else {
            listener.onResponse(AuthenticationResult.notHandled());
        }
    }

    @Override
    public void lookupUser(String username, ActionListener listener) {
        listener.onResponse(null);
    }

    private void buildUserFromClaims(JWTClaimsSet claims, ActionListener> authResultListener) {
        final String principal = principalAttribute.getClaimValue(claims);
        if (Strings.isNullOrEmpty(principal)) {
            authResultListener.onResponse(
                AuthenticationResult.unsuccessful(principalAttribute + "not found in " + claims.toJSONObject(), null)
            );
            return;
        }

        final Map tokenMetadata = new HashMap<>();
        tokenMetadata.put("id_token_hint", claims.getClaim("id_token_hint"));
        ActionListener> wrappedAuthResultListener = ActionListener.wrap(auth -> {
            if (auth.isAuthenticated()) {
                // Add the ID Token as metadata on the authentication, so that it can be used for logout requests
                Map metadata = new HashMap<>(auth.getMetadata());
                metadata.put(CONTEXT_TOKEN_DATA, tokenMetadata);
                auth = AuthenticationResult.success(auth.getValue(), metadata);
            }
            authResultListener.onResponse(auth);
        }, authResultListener::onFailure);

        if (delegatedRealms.hasDelegation()) {
            delegatedRealms.resolve(principal, wrappedAuthResultListener);
            return;
        }

        final Map userMetadata;
        if (populateUserMetadata) {
            userMetadata = claims.getClaims()
                .entrySet()
                .stream()
                .filter(entry -> isAllowedTypeForClaim(entry.getValue()))
                .collect(Collectors.toUnmodifiableMap(entry -> "oidc(" + entry.getKey() + ")", Map.Entry::getValue));
        } else {
            userMetadata = Map.of();
        }
        final List groups = groupsAttribute.getClaimValues(claims);
        final String dn = dnAttribute.getClaimValue(claims);
        final String mail = mailAttribute.getClaimValue(claims);
        final String name = nameAttribute.getClaimValue(claims);
        UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMetadata, config);
        roleMapper.resolveRoles(userData, ActionListener.wrap(roles -> {
            final User user = new User(principal, roles.toArray(Strings.EMPTY_ARRAY), name, mail, userMetadata, true);
            wrappedAuthResultListener.onResponse(AuthenticationResult.success(user));
        }, wrappedAuthResultListener::onFailure));

    }

    private static RelyingPartyConfiguration buildRelyingPartyConfiguration(RealmConfig config) {
        final String redirectUriString = require(config, RP_REDIRECT_URI);
        final URI redirectUri;
        try {
            redirectUri = new URI(redirectUriString);
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URI:" + RP_REDIRECT_URI.getKey(), e);
        }
        final String postLogoutRedirectUriString = config.getSetting(RP_POST_LOGOUT_REDIRECT_URI);
        final URI postLogoutRedirectUri;
        try {
            postLogoutRedirectUri = new URI(postLogoutRedirectUriString);
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URI:" + RP_POST_LOGOUT_REDIRECT_URI.getKey(), e);
        }
        final ClientID clientId = new ClientID(require(config, RP_CLIENT_ID));
        final SecureString clientSecret = config.getSetting(RP_CLIENT_SECRET);
        if (clientSecret.length() == 0) {
            throw new SettingsException(
                "The configuration setting [" + RealmSettings.getFullSettingKey(config, RP_CLIENT_SECRET) + "] is required"
            );
        }
        final ResponseType responseType;
        try {
            responseType = ResponseType.parse(require(config, RP_RESPONSE_TYPE));
        } catch (ParseException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid value for " + RP_RESPONSE_TYPE.getKey(), e);
        }

        final Scope requestedScope = new Scope(config.getSetting(RP_REQUESTED_SCOPES).toArray(Strings.EMPTY_ARRAY));
        if (requestedScope.contains("openid") == false) {
            requestedScope.add("openid");
        }
        final JWSAlgorithm signatureAlgorithm = JWSAlgorithm.parse(require(config, RP_SIGNATURE_ALGORITHM));
        final ClientAuthenticationMethod clientAuthenticationMethod = ClientAuthenticationMethod.parse(
            require(config, RP_CLIENT_AUTH_METHOD)
        );
        final JWSAlgorithm clientAuthJwtAlgorithm = JWSAlgorithm.parse(require(config, RP_CLIENT_AUTH_JWT_SIGNATURE_ALGORITHM));
        return new RelyingPartyConfiguration(
            clientId,
            clientSecret,
            redirectUri,
            responseType,
            requestedScope,
            signatureAlgorithm,
            clientAuthenticationMethod,
            clientAuthJwtAlgorithm,
            postLogoutRedirectUri
        );
    }

    private OpenIdConnectProviderConfiguration buildOpenIdConnectProviderConfiguration(RealmConfig config) {
        Issuer issuer = new Issuer(require(config, OP_ISSUER));

        String jwkSetUrl = require(config, OP_JWKSET_PATH);

        URI authorizationEndpoint;
        try {
            authorizationEndpoint = new URI(require(config, OP_AUTHORIZATION_ENDPOINT));
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URI: " + OP_AUTHORIZATION_ENDPOINT.getKey(), e);
        }
        String responseType = require(config, RP_RESPONSE_TYPE);
        String tokenEndpointString = config.getSetting(OP_TOKEN_ENDPOINT);
        if (responseType.equals("code") && tokenEndpointString.isEmpty()) {
            throw new SettingsException(
                "The configuration setting ["
                    + OP_TOKEN_ENDPOINT.getConcreteSettingForNamespace(name()).getKey()
                    + "] is required when ["
                    + RP_RESPONSE_TYPE.getConcreteSettingForNamespace(name()).getKey()
                    + "] is set to \"code\""
            );
        }
        URI tokenEndpoint;
        try {
            tokenEndpoint = tokenEndpointString.isEmpty() ? null : new URI(tokenEndpointString);
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URL: " + OP_TOKEN_ENDPOINT.getKey(), e);
        }
        URI userinfoEndpoint;
        try {
            userinfoEndpoint = (config.getSetting(OP_USERINFO_ENDPOINT).isEmpty())
                ? null
                : new URI(config.getSetting(OP_USERINFO_ENDPOINT));
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URI: " + OP_USERINFO_ENDPOINT.getKey(), e);
        }
        URI endsessionEndpoint;
        try {
            endsessionEndpoint = (config.getSetting(OP_ENDSESSION_ENDPOINT).isEmpty())
                ? null
                : new URI(config.getSetting(OP_ENDSESSION_ENDPOINT));
        } catch (URISyntaxException e) {
            // This should never happen as it's already validated in the settings
            throw new SettingsException("Invalid URI: " + OP_ENDSESSION_ENDPOINT.getKey(), e);
        }

        return new OpenIdConnectProviderConfiguration(
            issuer,
            jwkSetUrl,
            authorizationEndpoint,
            tokenEndpoint,
            userinfoEndpoint,
            endsessionEndpoint
        );
    }

    private static String require(RealmConfig config, Setting.AffixSetting setting) {
        final String value = config.getSetting(setting);
        if (value.isEmpty()) {
            throw new SettingsException("The configuration setting [" + RealmSettings.getFullSettingKey(config, setting) + "] is required");
        }
        return value;
    }

    /**
     * Creates the URI for an OIDC Authentication Request from the realm configuration using URI Query String Serialization and
     * possibly generates a state parameter and a nonce. It then returns the URI, state and nonce encapsulated in a
     * {@link OpenIdConnectPrepareAuthenticationResponse}. A facilitator can provide a state and a nonce parameter in two cases:
     * 
    *
  • In case of Kibana, it allows for a better UX by ensuring that all requests to an OpenID Connect Provider within * the same browser context (even across tabs) will use the same state and nonce values.
  • *
  • In case of custom facilitators, the implementer might require/support generating the state parameter in order * to tie this to an anti-XSRF token.
  • *
* * * @param existingState An existing state that can be reused or null if we need to generate one * @param existingNonce An existing nonce that can be reused or null if we need to generate one * @param loginHint A String with a login hint to add to the authentication request in case of a 3rd party initiated login * * @return an {@link OpenIdConnectPrepareAuthenticationResponse} */ public OpenIdConnectPrepareAuthenticationResponse buildAuthenticationRequestUri( @Nullable String existingState, @Nullable String existingNonce, @Nullable String loginHint ) { final State state = existingState != null ? new State(existingState) : new State(); final Nonce nonce = existingNonce != null ? new Nonce(existingNonce) : new Nonce(); final AuthenticationRequest.Builder builder = new AuthenticationRequest.Builder( rpConfiguration.getResponseType(), rpConfiguration.getRequestedScope(), rpConfiguration.getClientId(), rpConfiguration.getRedirectUri() ).endpointURI(opConfiguration.getAuthorizationEndpoint()).state(state).nonce(nonce); if (Strings.hasText(loginHint)) { builder.loginHint(loginHint); } return new OpenIdConnectPrepareAuthenticationResponse( builder.build().toURI().toString(), state.getValue(), nonce.getValue(), this.name() ); } public boolean isIssuerValid(String issuer) { return this.opConfiguration.getIssuer().getValue().equals(issuer); } public OpenIdConnectLogoutResponse buildLogoutResponse(JWT idTokenHint) { if (opConfiguration.getEndsessionEndpoint() != null) { final State state = new State(); final LogoutRequest logoutRequest = new LogoutRequest( opConfiguration.getEndsessionEndpoint(), idTokenHint, rpConfiguration.getPostLogoutRedirectUri(), state ); return new OpenIdConnectLogoutResponse(logoutRequest.toURI().toString()); } else { return new OpenIdConnectLogoutResponse((String) null); } } @Override public void close() { openIdConnectAuthenticator.close(); } /* * We only map claims that are of Type String, Boolean, or Number, or arrays that contain only these types */ private static boolean isAllowedTypeForClaim(Object o) { return (o instanceof String || o instanceof Boolean || o instanceof Number || (o instanceof Collection && ((Collection) o).stream().allMatch(c -> c instanceof String || c instanceof Boolean || c instanceof Number))); } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy