diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index f74c46161180..5c185a2a4411 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1363,6 +1363,7 @@ public class ApiConstants { public static final String CLIENT_ID = "clientid"; public static final String REDIRECT_URI = "redirecturi"; public static final String TOKEN_URL = "tokenurl"; + public static final String ISSUER_URL = "issuerurl"; public static final String IS_TAG_A_RULE = "istagarule"; diff --git a/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java b/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java index cccc1fff9823..856ebc7b50fe 100644 --- a/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java +++ b/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java @@ -56,6 +56,24 @@ public interface UserOAuth2Authenticator extends Adapter { */ String verifySecretCodeAndFetchEmail(String secretCode, Long domainId); + /** + * Verifies the user against the registration identified by providerName. Implementations that + * serve a single registration ignore the name; implementations shared by several registrations + * use it to select the one to authenticate against. + * @return true if it's a valid user, otherwise false + */ + default boolean verifyUser(String email, String secretCode, Long domainId, String providerName) { + return verifyUser(email, secretCode, domainId); + } + + /** + * Verifies the secret code against the registration identified by providerName and fetches email. + * @return email for the specified registration + */ + default String verifySecretCodeAndFetchEmail(String secretCode, Long domainId, String providerName) { + return verifySecretCodeAndFetchEmail(secretCode, domainId); + } + /** * Fetches email using the accessToken * @return returns email diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql index 7c11013a17d2..86e415f24b22 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql @@ -18,3 +18,7 @@ --; -- Schema upgrade from 4.23.0.0 to 24.0.0 --; + +-- Generic OIDC OAuth2 provider: a type to select the implementation, and the issuer URL for discovery +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider', 'type', 'VARCHAR(40) DEFAULT NULL COMMENT ''Provider implementation serving this registration, for example oidc; NULL means the provider name selects the implementation'' AFTER `token_url` '); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider', 'issuer_url', 'VARCHAR(255) DEFAULT NULL COMMENT ''Issuer URL of the OpenID Connect provider, used to read its discovery document'' AFTER `type` '); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManager.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManager.java index 133131d3928a..e3de001956ad 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManager.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManager.java @@ -55,6 +55,16 @@ public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableS */ UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName); + /** + * Finds the user OAuth2 provider serving the named registration within a domain scope. A name + * that matches no provider plugin is resolved through the type of its registration, so that a + * generic provider can serve registrations under administrator chosen names. + * @param providerName name of the registration + * @param domainId domain id, or null for global + * @return OAuth2 provider + */ + UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName, final Long domainId); + String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId); OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java index c3bad43be40e..57ee1392cfcf 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java @@ -125,13 +125,30 @@ public List listUserOAuth2AuthenticationProviders() { @Override public UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(String providerName) { + return getUserOAuth2AuthenticationProvider(providerName, null); + } + + @Override + public UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(String providerName, Long domainId) { if (StringUtils.isEmpty(providerName)) { throw new CloudRuntimeException("OAuth2 authentication provider name is empty"); } - if (!userOAuth2AuthenticationProvidersMap.containsKey(providerName.toLowerCase())) { + UserOAuth2Authenticator authenticator = userOAuth2AuthenticationProvidersMap.get(providerName.toLowerCase()); + if (authenticator == null) { + authenticator = findAuthenticatorByRegisteredType(providerName, domainId); + } + if (authenticator == null) { throw new CloudRuntimeException(String.format("Failed to find OAuth2 authentication provider by the name: %s.", providerName)); } - return userOAuth2AuthenticationProvidersMap.get(providerName.toLowerCase()); + return authenticator; + } + + protected UserOAuth2Authenticator findAuthenticatorByRegisteredType(String providerName, Long domainId) { + OauthProviderVO registration = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(providerName, domainId); + if (registration == null || StringUtils.isBlank(registration.getType())) { + return null; + } + return userOAuth2AuthenticationProvidersMap.get(registration.getType().toLowerCase()); } public List getUserOAuth2AuthenticationProviders() { @@ -152,8 +169,8 @@ protected void initializeUserOAuth2AuthenticationProvidersMap() { @Override public String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId) { - UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider); - String email = authenticator.verifySecretCodeAndFetchEmail(code, domainId); + UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider, domainId); + String email = authenticator.verifySecretCodeAndFetchEmail(code, domainId, provider); return email; } @@ -168,11 +185,17 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) { Long domainId = normalizeGlobalScope(resolveDomainIdFromIdOrPath(cmd.getDomainId(), cmd.getDomainPath())); String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); + String type = StringUtils.trim(cmd.getType()); + String issuerUrl = StringUtils.trim(cmd.getIssuerUrl()); if (!isOAuthPluginEnabled(domainId)) { throw new CloudRuntimeException("OAuth is not enabled, please enable to register"); } + if (StringUtils.isNotBlank(type) && !userOAuth2AuthenticationProvidersMap.containsKey(type.toLowerCase())) { + throw new CloudRuntimeException(String.format("No OAuth2 provider plugin is available for the type %s", type)); + } + // Check for existing provider with same name and domain OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomain(provider, domainId); if (providerVO != null) { @@ -183,7 +206,7 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) { } } - return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl, domainId); + return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl, domainId, type, issuerUrl); } @Override @@ -212,6 +235,7 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { String secretKey = StringUtils.trim(cmd.getSecretKey()); String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); + String issuerUrl = StringUtils.trim(cmd.getIssuerUrl()); Boolean enabled = cmd.getEnabled(); OauthProviderVO providerVO = _oauthProviderDao.findById(id); @@ -261,6 +285,9 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { if (StringUtils.isNotEmpty(tokenUrl)) { providerVO.setTokenUrl(tokenUrl); } + if (StringUtils.isNotEmpty(issuerUrl)) { + providerVO.setIssuerUrl(issuerUrl); + } if (enabled != null) { providerVO.setEnabled(enabled); } @@ -271,7 +298,8 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { return _oauthProviderDao.findById(id); } - private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl, Long domainId) { + private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, + String tokenUrl, Long domainId, String type, String issuerUrl) { final OauthProviderVO oauthProviderVO = new OauthProviderVO(); oauthProviderVO.setProvider(provider); @@ -282,6 +310,8 @@ private OauthProviderVO saveOauthProvider(String provider, String description, S oauthProviderVO.setDomainId(domainId); oauthProviderVO.setAuthorizeUrl(authorizeUrl); oauthProviderVO.setTokenUrl(tokenUrl); + oauthProviderVO.setType(type); + oauthProviderVO.setIssuerUrl(issuerUrl); oauthProviderVO.setEnabled(true); _oauthProviderDao.persist(oauthProviderVO); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticator.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticator.java index 49df94709836..395e668dfc87 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticator.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticator.java @@ -74,8 +74,8 @@ public Pair authenticate(String username, String email = ((emailArray == null) ? null : emailArray[0]); String secretCode = ((secretCodeArray == null) ? null : secretCodeArray[0]); - UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider); - if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId)) { + UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider, domainId); + if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId, oauthProvider)) { return new Pair(true, null); } } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java index 9c72e47e5595..dc337319c09f 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java @@ -46,6 +46,7 @@ import org.apache.commons.lang.ArrayUtils; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; import com.cloud.api.ApiDBUtils; import com.cloud.api.ApiServer; @@ -184,8 +185,10 @@ public String authenticate(String command, Map params, HttpSes OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), result.getDescription(), result.getClientId(), secretKeyAllowed ? result.getSecretKey() : null, result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl(), domain); + r.setType(result.getType()); + r.setIssuerUrl(result.getIssuerUrl()); boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId()); - if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) { + if (oauthEnabled && isServedByPlugin(result, authenticatorPluginNames) && result.isEnabled()) { r.setEnabled(true); } else { r.setEnabled(false); @@ -200,7 +203,7 @@ public String authenticate(String command, Map params, HttpSes for (OauthProviderVO domainProvider : allProviders) { if (domainProvider.getDomainId() != null && domainProvider.isEnabled() && OAuth2AuthManager.isPluginEnabledForDomain(domainProvider.getDomainId()) - && authenticatorPluginNames.contains(domainProvider.getProvider())) { + && isServedByPlugin(domainProvider, authenticatorPluginNames)) { totalEnabledCount++; } } @@ -214,6 +217,11 @@ public String authenticate(String command, Map params, HttpSes return ApiResponseSerializer.toSerializedString(response, responseType); } + protected boolean isServedByPlugin(OauthProviderVO provider, List authenticatorPluginNames) { + return authenticatorPluginNames.contains(provider.getProvider()) + || (StringUtils.isNotBlank(provider.getType()) && authenticatorPluginNames.contains(provider.getType())); + } + @Override public APIAuthenticationType getAPIType() { return null; diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java index 956e6fca00af..42b5d6dc50d8 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java @@ -32,6 +32,7 @@ import org.apache.cloudstack.oauth2.OAuth2AuthManager; import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; import org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider; +import org.apache.cloudstack.oauth2.oidc.GenericOIDCOAuth2Provider; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -76,6 +77,16 @@ public class RegisterOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)", since = "4.23.0") private String tokenUrl; + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, + description = "Type of the provider implementation serving this registration, for example oidc for any OpenID Connect compliant provider. " + + "When set, the name in provider is a label chosen by the administrator rather than a built in provider name.", since = "4.24.0") + private String type; + + @Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING, + description = "Issuer URL of the OpenID Connect provider, required for type oidc. The token endpoint and the keys that sign " + + "its tokens are read from the issuer's discovery document", since = "4.24.0") + private String issuerUrl; + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Any OAuth provider details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].clientsecret=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") protected Map details; @@ -121,6 +132,14 @@ public String getTokenUrl() { return tokenUrl; } + public String getType() { + return type; + } + + public String getIssuerUrl() { + return issuerUrl; + } + public Map getDetails() { if (MapUtils.isEmpty(details)) { return null; @@ -143,12 +162,18 @@ public void execute() throws ServerApiException, ConcurrentOperationException, E } } + if (StringUtils.equalsIgnoreCase(GenericOIDCOAuth2Provider.OIDC_PROVIDER_TYPE, getType()) && StringUtils.isBlank(getIssuerUrl())) { + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter issuerurl is mandatory for an oidc OAuth provider"); + } + OauthProviderVO provider = _oauth2mgr.registerOauthProvider(this); Domain domain = provider.getDomainId() != null ? ApiDBUtils.findDomainById(provider.getDomainId()) : null; OauthProviderResponse response = new OauthProviderResponse(provider.getUuid(), provider.getProvider(), provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri(), provider.getAuthorizeUrl(), provider.getTokenUrl(), domain); + response.setType(provider.getType()); + response.setIssuerUrl(provider.getIssuerUrl()); response.setResponseName(getCommandName()); response.setObjectName(ApiConstants.OAUTH_PROVIDER); setResponseObject(response); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java index f6e60caaade7..5a4603b225e3 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java @@ -34,6 +34,7 @@ import org.apache.cloudstack.oauth2.OAuth2AuthManager; import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; import com.cloud.api.ApiDBUtils; import com.cloud.domain.Domain; @@ -67,6 +68,10 @@ public final class UpdateOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL pre-registered in the specific OAuth provider", since = "4.23.0") private String tokenUrl; + @Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING, + description = "Issuer URL of the OpenID Connect provider, used to read its discovery document", since = "4.24.0") + private String issuerUrl; + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value") private Boolean enabled; @@ -113,6 +118,10 @@ public String getTokenUrl() { return tokenUrl; } + public String getIssuerUrl() { + return issuerUrl; + } + public Boolean getEnabled() { return enabled; } @@ -152,6 +161,8 @@ public void execute() { OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl(), domain); + r.setType(result.getType()); + r.setIssuerUrl(result.getIssuerUrl()); List userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders(); List authenticatorPluginNames = new ArrayList<>(); @@ -159,8 +170,10 @@ public void execute() { String name = authenticator.getName(); authenticatorPluginNames.add(name); } + boolean servedByPlugin = authenticatorPluginNames.contains(result.getProvider()) + || (StringUtils.isNotBlank(result.getType()) && authenticatorPluginNames.contains(result.getType())); boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId()); - if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) { + if (oauthEnabled && servedByPlugin && result.isEnabled()) { r.setEnabled(true); } else { r.setEnabled(false); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java index b363e13516bc..603ace84654f 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java @@ -70,6 +70,14 @@ public class OauthProviderResponse extends BaseResponse { @Param(description = "path of the domain the provider belongs to (empty for global)", since = "4.23.0") private String domainPath; + @SerializedName(ApiConstants.TYPE) + @Param(description = "Type of the provider, for example oidc for a generic OpenID Connect provider. Empty for the built in providers", since = "4.24.0") + private String type; + + @SerializedName(ApiConstants.ISSUER_URL) + @Param(description = "Issuer URL of the OpenID Connect provider, used for discovery", since = "4.24.0") + private String issuerUrl; + @SerializedName(ApiConstants.AUTHORIZE_URL) @Param(description = "Authorize URL registered in the OAuth provider") private String authorizeUrl; @@ -180,6 +188,22 @@ public void setDomainPath(String domainPath) { this.domainPath = domainPath; } + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getIssuerUrl() { + return issuerUrl; + } + + public void setIssuerUrl(String issuerUrl) { + this.issuerUrl = issuerUrl; + } + public String getAuthorizeUrl() { return authorizeUrl; } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java new file mode 100644 index 000000000000..f7a149d468db --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java @@ -0,0 +1,350 @@ +// +// 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.cloudstack.oauth2.oidc; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.ws.rs.core.HttpHeaders; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKey; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKeys; +import org.apache.cxf.rs.security.jose.jwk.JwkUtils; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.cxf.rs.security.jose.jwt.JwtUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * A single provider implementation for any OIDC compliant identity provider. Unlike the per vendor + * providers it is not bound to one registration: the registration is selected by name on every call, + * so one bean serves any number of registrations of type "oidc". + */ +public class GenericOIDCOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + + public static final String OIDC_PROVIDER_TYPE = "oidc"; + + private static final String DISCOVERY_PATH = "/.well-known/openid-configuration"; + private static final int CLOCK_SKEW_SECONDS = 60; + private static final long METADATA_CACHE_MINUTES = 60; + private static final long VERIFIED_EMAIL_CACHE_SECONDS = 60; + private static final int HTTP_TIMEOUT_MILLIS = 10000; + + @Inject + OauthProviderDao oauthProviderDao; + + private CloseableHttpClient httpClient; + + private final Cache metadataCache = + Caffeine.newBuilder() + .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES) + .maximumSize(64) + .build(); + + private final Cache verifiedEmailCache = + Caffeine.newBuilder() + .expireAfterWrite(VERIFIED_EMAIL_CACHE_SECONDS, TimeUnit.SECONDS) + .maximumSize(1024) + .build(); + + public GenericOIDCOAuth2Provider() { + this(HttpClientBuilder.create() + .setDefaultRequestConfig(RequestConfig.custom() + .setConnectTimeout(HTTP_TIMEOUT_MILLIS) + .setConnectionRequestTimeout(HTTP_TIMEOUT_MILLIS) + .setSocketTimeout(HTTP_TIMEOUT_MILLIS) + .build()) + .build()); + } + + public GenericOIDCOAuth2Provider(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public String getName() { + return OIDC_PROVIDER_TYPE; + } + + @Override + public String getDescription() { + return "Generic OpenID Connect Provider Plugin"; + } + + @Override + public boolean verifyUser(String email, String secretCode) { + throw new CloudRuntimeException("The generic OIDC provider requires the registered provider name"); + } + + @Override + public boolean verifyUser(String email, String secretCode, Long domainId) { + throw new CloudRuntimeException("The generic OIDC provider requires the registered provider name"); + } + + @Override + public String verifySecretCodeAndFetchEmail(String secretCode) { + throw new CloudRuntimeException("The generic OIDC provider requires the registered provider name"); + } + + @Override + public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) { + throw new CloudRuntimeException("The generic OIDC provider requires the registered provider name"); + } + + @Override + public boolean verifyUser(String email, String secretCode, Long domainId, String providerName) { + if (StringUtils.isAnyEmpty(email, secretCode)) { + throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); + } + + String verifiedEmail = verifiedEmailCache.asMap().remove(verifiedEmailKey(providerName, secretCode)); + if (verifiedEmail == null) { + verifiedEmail = resolveEmail(secretCode, domainId, providerName); + } + if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) { + throw new CloudRuntimeException("Unable to verify the email address with the provided secret"); + } + + return true; + } + + @Override + public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId, String providerName) { + String email = resolveEmail(secretCode, domainId, providerName); + verifiedEmailCache.put(verifiedEmailKey(providerName, secretCode), email); + return email; + } + + protected String resolveEmail(String secretCode, Long domainId, String providerName) { + OauthProviderVO provider = findRegistration(providerName, domainId); + OIDCMetadata metadata = getMetadata(provider); + String idToken = exchangeAuthorizationCode(secretCode, provider, metadata); + + return validateAndExtractEmail(idToken, provider, metadata); + } + + @Override + public String getUserEmailAddress() throws CloudRuntimeException { + return null; + } + + private String verifiedEmailKey(String providerName, String secretCode) { + return DigestUtils.sha256Hex(providerName + ":" + secretCode); + } + + protected OauthProviderVO findRegistration(String providerName, Long domainId) { + if (StringUtils.isBlank(providerName)) { + throw new CloudAuthenticationException("The registered provider name is required"); + } + OauthProviderVO provider = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(providerName, domainId); + if (provider == null) { + throw new CloudAuthenticationException(String.format("%s provider is not registered, so user cannot be verified", providerName)); + } + return provider; + } + + protected OIDCMetadata getMetadata(OauthProviderVO provider) { + String issuerUrl = StringUtils.trimToNull(provider.getIssuerUrl()); + if (issuerUrl == null) { + throw new CloudRuntimeException(String.format( + "Provider %s has no issuer URL, so its endpoints and signing keys cannot be discovered", provider.getProvider())); + } + return metadataCache.get(issuerUrl, this::discover); + } + + protected OIDCMetadata discover(String issuerUrl) { + String document = httpGet(StringUtils.removeEnd(issuerUrl, "/") + DISCOVERY_PATH, + String.format("Unable to read the OpenID Connect discovery document from %s", issuerUrl)); + + JsonObject json = JsonParser.parseString(document).getAsJsonObject(); + String issuer = readString(json, "issuer"); + String tokenEndpoint = readString(json, "token_endpoint"); + String jwksUri = readString(json, "jwks_uri"); + if (StringUtils.isAnyBlank(issuer, tokenEndpoint, jwksUri)) { + throw new CloudRuntimeException(String.format( + "The discovery document at %s is missing the issuer, the token endpoint or the JWKS URI", issuerUrl)); + } + if (!StringUtils.removeEnd(issuer, "/").equals(StringUtils.removeEnd(issuerUrl, "/"))) { + throw new CloudRuntimeException(String.format("The discovery document at %s names a different issuer: %s", issuerUrl, issuer)); + } + + return new OIDCMetadata(issuer, readString(json, "authorization_endpoint"), tokenEndpoint, jwksUri); + } + + protected String exchangeAuthorizationCode(String secretCode, OauthProviderVO provider, OIDCMetadata metadata) { + String auth = provider.getClientId() + ":" + provider.getSecretKey(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "authorization_code")); + params.add(new BasicNameValuePair("code", secretCode)); + params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); + + HttpPost post = new HttpPost(metadata.getTokenEndpoint()); + post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + try { + post.setEntity(new UrlEncodedFormEntity(params)); + } catch (UnsupportedEncodingException e) { + throw new CloudRuntimeException("Unable to generate URL parameters: " + e.getMessage()); + } + + try (CloseableHttpResponse response = httpClient.execute(post)) { + String body = EntityUtils.toString(response.getEntity()); + if (response.getStatusLine().getStatusCode() != 200) { + throw new CloudRuntimeException(String.format("%s error during token generation: %s", provider.getProvider(), body)); + } + + JsonElement fetchedIdToken = JsonParser.parseString(body).getAsJsonObject().get("id_token"); + if (fetchedIdToken == null) { + throw new CloudRuntimeException("No id_token found in token"); + } + return fetchedIdToken.getAsString(); + } catch (IOException e) { + throw new CloudRuntimeException(String.format("Unable to connect to the %s token endpoint", provider.getProvider()), e); + } + } + + protected String validateAndExtractEmail(String idToken, OauthProviderVO provider, OIDCMetadata metadata) { + JwsJwtCompactConsumer consumer = new JwsJwtCompactConsumer(idToken); + + verifySignature(consumer, metadata, provider); + + JwtClaims claims = consumer.getJwtClaims(); + if (!metadata.getIssuer().equals(claims.getIssuer())) { + throw new CloudAuthenticationException("Issuer mismatch"); + } + if (!claims.getAudiences().contains(provider.getClientId())) { + throw new CloudAuthenticationException("Audience mismatch"); + } + JwtUtils.validateJwtExpiry(claims, CLOCK_SKEW_SECONDS, true); + + String email = (String) claims.getClaim("email"); + if (StringUtils.isBlank(email)) { + throw new CloudAuthenticationException("The id_token carries no email claim"); + } + return email; + } + + protected void verifySignature(JwsJwtCompactConsumer consumer, OIDCMetadata metadata, OauthProviderVO provider) { + if (StringUtils.isBlank(metadata.getJwksUri())) { + throw new CloudAuthenticationException(String.format( + "Provider %s has no JWKS endpoint, so the id_token signature cannot be verified", provider.getProvider())); + } + + JsonWebKeys keys = readJwkSet(metadata.getJwksUri()); + String keyId = consumer.getJwsHeaders().getKeyId(); + + JsonWebKey key = StringUtils.isNotBlank(keyId) ? keys.getKey(keyId) : singleKey(keys); + if (key == null) { + throw new CloudAuthenticationException("No matching signing key was published by the identity provider"); + } + if (!consumer.verifySignatureWith(key)) { + throw new CloudAuthenticationException("The id_token signature is not valid"); + } + } + + protected JsonWebKeys readJwkSet(String jwksUri) { + return JwkUtils.readJwkSet(httpGet(jwksUri, String.format("Unable to read the signing keys from %s", jwksUri))); + } + + private JsonWebKey singleKey(JsonWebKeys keys) { + List published = keys.getKeys(); + return published != null && published.size() == 1 ? published.get(0) : null; + } + + protected String httpGet(String url, String failureMessage) { + try (CloseableHttpResponse response = httpClient.execute(new HttpGet(url))) { + String body = EntityUtils.toString(response.getEntity()); + if (response.getStatusLine().getStatusCode() != 200) { + throw new CloudRuntimeException(String.format("%s: %s", failureMessage, body)); + } + return body; + } catch (IOException e) { + throw new CloudRuntimeException(failureMessage, e); + } + } + + private String readString(JsonObject json, String member) { + JsonElement element = json.get(member); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + public void setHttpClient(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + protected static class OIDCMetadata { + private final String issuer; + private final String authorizationEndpoint; + private final String tokenEndpoint; + private final String jwksUri; + + protected OIDCMetadata(String issuer, String authorizationEndpoint, String tokenEndpoint, String jwksUri) { + this.issuer = issuer; + this.authorizationEndpoint = authorizationEndpoint; + this.tokenEndpoint = tokenEndpoint; + this.jwksUri = jwksUri; + } + + public String getIssuer() { + return issuer; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getJwksUri() { + return jwksUri; + } + } +} diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java index 8aa9006e763e..4fe701315120 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java @@ -60,12 +60,18 @@ public class OauthProviderVO implements Identity, InternalIdentity { @Column(name = "domain_id") private Long domainId; + @Column(name = "type") + private String type; + @Column(name = "authorize_url") private String authorizeUrl; @Column(name = "token_url") private String tokenUrl; + @Column(name = "issuer_url") + private String issuerUrl; + @Column(name = GenericDao.CREATED_COLUMN) private Date created; @@ -121,6 +127,22 @@ public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getIssuerUrl() { + return issuerUrl; + } + + public void setIssuerUrl(String issuerUrl) { + this.issuerUrl = issuerUrl; + } + public String getAuthorizeUrl() { return authorizeUrl; } diff --git a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml index 06fe60f4c25e..a7b5f2f6001e 100644 --- a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml +++ b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml @@ -38,6 +38,9 @@ + + + @@ -48,7 +51,7 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> - + diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImplTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImplTest.java index e3e8f7594b37..df4113ba11e5 100644 --- a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImplTest.java +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImplTest.java @@ -525,6 +525,67 @@ public void testGetUserOAuth2AuthenticationProviderNotFound() { } } + @Test + public void testGetUserOAuth2AuthenticationProviderResolvesRegistrationByType() { + org.apache.cloudstack.auth.UserOAuth2Authenticator oidcProvider = + Mockito.mock(org.apache.cloudstack.auth.UserOAuth2Authenticator.class); + OAuth2AuthManagerImpl.userOAuth2AuthenticationProvidersMap.put("oidc", oidcProvider); + try { + OauthProviderVO registration = new OauthProviderVO(); + registration.setProvider("corp-idp"); + registration.setType("oidc"); + when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback("corp-idp", 5L)).thenReturn(registration); + + assertEquals(oidcProvider, _authManager.getUserOAuth2AuthenticationProvider("corp-idp", 5L)); + } finally { + OAuth2AuthManagerImpl.userOAuth2AuthenticationProvidersMap.remove("oidc"); + } + } + + @Test + public void testGetUserOAuth2AuthenticationProviderRejectsRegistrationWithoutType() { + OauthProviderVO registration = new OauthProviderVO(); + registration.setProvider("corp-idp"); + when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback("corp-idp", null)).thenReturn(registration); + + try { + _authManager.getUserOAuth2AuthenticationProvider("corp-idp", null); + Assert.fail("Expected CloudRuntimeException was not thrown"); + } catch (CloudRuntimeException e) { + assertTrue(e.getMessage().contains("corp-idp")); + } + } + + @Test + public void testGetUserOAuth2AuthenticationProviderPrefersRegisteredPluginName() { + org.apache.cloudstack.auth.UserOAuth2Authenticator githubProvider = + Mockito.mock(org.apache.cloudstack.auth.UserOAuth2Authenticator.class); + OAuth2AuthManagerImpl.userOAuth2AuthenticationProvidersMap.put("github", githubProvider); + try { + assertEquals(githubProvider, _authManager.getUserOAuth2AuthenticationProvider("github", 5L)); + Mockito.verify(_oauthProviderDao, Mockito.never()) + .findByProviderAndDomainWithGlobalFallback(Mockito.anyString(), Mockito.anyLong()); + } finally { + OAuth2AuthManagerImpl.userOAuth2AuthenticationProvidersMap.remove("github"); + } + } + + @Test + public void testRegisterOauthProviderRejectsUnknownType() { + when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true); + RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class); + when(cmd.getProvider()).thenReturn("corp-idp"); + when(cmd.getType()).thenReturn("saml"); + + try { + _authManager.registerOauthProvider(cmd); + Assert.fail("Expected CloudRuntimeException was not thrown"); + } catch (CloudRuntimeException e) { + assertTrue(e.getMessage().contains("saml")); + } + Mockito.verify(_oauthProviderDao, Mockito.never()).persist(Mockito.any(OauthProviderVO.class)); + } + // Multiple-domain OAuth tests @Test diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticatorTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticatorTest.java index 1351c1ea4791..aa960cc5643c 100644 --- a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticatorTest.java +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticatorTest.java @@ -92,8 +92,8 @@ public void testAuthenticateWithValidCredentials() { when(userAccountDao.getUserAccount(username, domainId)).thenReturn(userAccount); when(userDao.getUser(userAccount.getId())).thenReturn(user); - when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0])).thenReturn(userOAuth2Authenticator); - when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId)).thenReturn(true); + when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0], domainId)).thenReturn(userOAuth2Authenticator); + when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId, provider[0])).thenReturn(true); Map requestParameters = new HashMap<>(); requestParameters.put("provider", provider); @@ -107,8 +107,8 @@ public void testAuthenticateWithValidCredentials() { verify(userAccountDao).getUserAccount(username, domainId); verify(userDao).getUser(userAccount.getId()); - verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0]); - verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId); + verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0], domainId); + verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId, provider[0]); } @Test @@ -125,8 +125,8 @@ public void testAuthenticateWithInvalidCredentials() { when(userAccountDao.getUserAccount(username, domainId)).thenReturn(userAccount); when(userDao.getUser(userAccount.getId())).thenReturn(user); - when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0])).thenReturn(userOAuth2Authenticator); - when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId)).thenReturn(false); + when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0], domainId)).thenReturn(userOAuth2Authenticator); + when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId, provider[0])).thenReturn(false); Map requestParameters = new HashMap<>(); requestParameters.put("provider", provider); @@ -140,8 +140,8 @@ public void testAuthenticateWithInvalidCredentials() { verify(userAccountDao).getUserAccount(username, domainId); verify(userDao).getUser(userAccount.getId()); - verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0]); - verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId); + verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0], domainId); + verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId, provider[0]); } @Test @@ -166,7 +166,7 @@ public void testAuthenticateWithInvalidUserAccount() { verify(userAccountDao).getUserAccount(username, domainId); verify(userDao, never()).getUser(anyLong()); - verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString()); + verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString(), anyLong()); } @Test @@ -210,6 +210,6 @@ public void testAuthenticateNullProvider() { assertFalse(result.first()); assertNull(result.second()); - verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString()); + verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString(), anyLong()); } } diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2ProviderTest.java new file mode 100644 index 000000000000..75b25e531373 --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2ProviderTest.java @@ -0,0 +1,371 @@ +// +// 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.cloudstack.oauth2.oidc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import java.util.Collections; + +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.cxf.rs.security.jose.jwa.SignatureAlgorithm; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKey; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKeys; +import org.apache.cxf.rs.security.jose.jwk.JwkUtils; +import org.apache.cxf.rs.security.jose.jws.JwsHeaders; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactProducer; +import org.apache.cxf.rs.security.jose.jws.JwsUtils; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.exception.CloudRuntimeException; + +public class GenericOIDCOAuth2ProviderTest { + + private static final String REGISTRATION = "corp-idp"; + private static final String ISSUER = "https://idp.example.com"; + private static final String CLIENT_ID = "test-client"; + + @Mock + private OauthProviderDao oauthProviderDao; + + @Mock + private CloseableHttpClient httpClient; + + private GenericOIDCOAuth2Provider provider; + + private OauthProviderVO registration; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + + provider = Mockito.spy(new GenericOIDCOAuth2Provider(httpClient)); + provider.oauthProviderDao = oauthProviderDao; + + registration = new OauthProviderVO(); + registration.setProvider(REGISTRATION); + registration.setType(GenericOIDCOAuth2Provider.OIDC_PROVIDER_TYPE); + registration.setClientId(CLIENT_ID); + registration.setSecretKey("test-secret"); + registration.setRedirectUri("http://localhost/redirect"); + registration.setIssuerUrl(ISSUER); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + private String idToken(String issuer, String audience, String email, long expiresInSeconds) { + String header = "{\"alg\":\"RS256\",\"kid\":\"key-1\"}"; + String payload = "{" + + "\"iss\":\"" + issuer + "\"," + + "\"aud\":[\"" + audience + "\"]," + + (email == null ? "" : "\"email\":\"" + email + "\",") + + "\"exp\":" + (System.currentTimeMillis() / 1000L + expiresInSeconds) + "," + + "\"sub\":\"12345\"" + + "}"; + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + return encoder.encodeToString(header.getBytes(StandardCharsets.UTF_8)) + "." + + encoder.encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + ".signature"; + } + + private GenericOIDCOAuth2Provider.OIDCMetadata metadata() { + return new GenericOIDCOAuth2Provider.OIDCMetadata(ISSUER, ISSUER + "/authorize", ISSUER + "/token", ISSUER + "/jwks"); + } + + @Test + public void testNameIsTheProviderType() { + assertEquals(GenericOIDCOAuth2Provider.OIDC_PROVIDER_TYPE, provider.getName()); + assertNull(provider.getUserEmailAddress()); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithoutRegistrationNameIsRejected() { + provider.verifyUser("user@example.com", "code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifySecretCodeWithoutRegistrationNameIsRejected() { + provider.verifySecretCodeAndFetchEmail("code"); + } + + @Test(expected = CloudAuthenticationException.class) + public void testBlankRegistrationNameIsRejected() { + provider.findRegistration(" ", null); + } + + @Test(expected = CloudAuthenticationException.class) + public void testUnregisteredProviderIsRejected() { + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(null); + provider.findRegistration(REGISTRATION, null); + } + + @Test(expected = CloudRuntimeException.class) + public void testRegistrationWithoutIssuerIsRejected() { + registration.setIssuerUrl(null); + registration.setAuthorizeUrl(ISSUER + "/authorize"); + registration.setTokenUrl(ISSUER + "/token"); + provider.getMetadata(registration); + } + + @Test + public void testDiscoveryReadsTheEndpoints() { + String document = "{" + + "\"issuer\":\"" + ISSUER + "\"," + + "\"authorization_endpoint\":\"" + ISSUER + "/authorize\"," + + "\"token_endpoint\":\"" + ISSUER + "/token\"," + + "\"jwks_uri\":\"" + ISSUER + "/jwks\"}"; + doReturn(document).when(provider).httpGet(eq(ISSUER + "/.well-known/openid-configuration"), anyString()); + + GenericOIDCOAuth2Provider.OIDCMetadata metadata = provider.discover(ISSUER); + + assertEquals(ISSUER, metadata.getIssuer()); + assertEquals(ISSUER + "/token", metadata.getTokenEndpoint()); + assertEquals(ISSUER + "/jwks", metadata.getJwksUri()); + } + + @Test(expected = CloudRuntimeException.class) + public void testDiscoveryWithoutTokenEndpointIsRejected() { + doReturn("{\"issuer\":\"" + ISSUER + "\"}").when(provider).httpGet(anyString(), anyString()); + provider.discover(ISSUER); + } + + @Test(expected = CloudRuntimeException.class) + public void testDiscoveryWithoutJwksIsRejected() { + doReturn("{\"issuer\":\"" + ISSUER + "\",\"token_endpoint\":\"" + ISSUER + "/token\"}") + .when(provider).httpGet(anyString(), anyString()); + provider.discover(ISSUER); + } + + @Test(expected = CloudRuntimeException.class) + public void testDiscoveryNamingAnotherIssuerIsRejected() { + doReturn("{\"issuer\":\"https://attacker.example.com\",\"token_endpoint\":\"" + ISSUER + "/token\"," + + "\"jwks_uri\":\"" + ISSUER + "/jwks\"}").when(provider).httpGet(anyString(), anyString()); + provider.discover(ISSUER); + } + + private KeyPair rsaKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private String signedIdToken(KeyPair keys, String keyId, String email) { + JwtClaims claims = new JwtClaims(); + claims.setIssuer(ISSUER); + claims.setAudiences(Collections.singletonList(CLIENT_ID)); + claims.setSubject("12345"); + claims.setExpiryTime(System.currentTimeMillis() / 1000L + 3600); + claims.setClaim("email", email); + JwsHeaders headers = new JwsHeaders(SignatureAlgorithm.RS256); + headers.setKeyId(keyId); + return new JwsJwtCompactProducer(headers, claims) + .signWith(JwsUtils.getPrivateKeySignatureProvider(keys.getPrivate(), SignatureAlgorithm.RS256)); + } + + private void publishKey(KeyPair keys, String keyId) { + JsonWebKey key = JwkUtils.fromRSAPublicKey((RSAPublicKey) keys.getPublic(), "RS256"); + key.setKeyId(keyId); + doReturn(JwkUtils.jwkSetToJson(new JsonWebKeys(key))).when(provider).httpGet(eq(ISSUER + "/jwks"), anyString()); + } + + @Test + public void testGenuinelySignedTokenIsAccepted() throws Exception { + KeyPair keys = rsaKeyPair(); + publishKey(keys, "key-1"); + + assertEquals("user@example.com", + provider.validateAndExtractEmail(signedIdToken(keys, "key-1", "user@example.com"), registration, metadata())); + } + + @Test(expected = CloudAuthenticationException.class) + public void testTokenWithAlteredClaimsIsRejected() throws Exception { + KeyPair keys = rsaKeyPair(); + publishKey(keys, "key-1"); + String[] parts = signedIdToken(keys, "key-1", "user@example.com").split("\\."); + String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8) + .replace("user@example.com", "admin@example.com"); + String tampered = parts[0] + "." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + "." + parts[2]; + + provider.validateAndExtractEmail(tampered, registration, metadata()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testTokenSignedByAnotherKeyIsRejected() throws Exception { + publishKey(rsaKeyPair(), "key-1"); + + provider.validateAndExtractEmail(signedIdToken(rsaKeyPair(), "key-1", "user@example.com"), registration, metadata()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testTokenNamingAnUnpublishedKeyIsRejected() throws Exception { + KeyPair keys = rsaKeyPair(); + publishKey(keys, "key-1"); + + provider.validateAndExtractEmail(signedIdToken(keys, "key-2", "user@example.com"), registration, metadata()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testUnsignedTokenIsRejectedWhenNoJwksIsPublished() { + GenericOIDCOAuth2Provider.OIDCMetadata noJwks = + new GenericOIDCOAuth2Provider.OIDCMetadata(ISSUER, null, ISSUER + "/token", null); + provider.validateAndExtractEmail(idToken(ISSUER, CLIENT_ID, "user@example.com", 3600), registration, noJwks); + } + + @Test(expected = CloudAuthenticationException.class) + public void testIssuerMismatchIsRejected() { + doNothing().when(provider).verifySignature(any(), any(), any()); + provider.validateAndExtractEmail(idToken("https://attacker.example.com", CLIENT_ID, "user@example.com", 3600), + registration, metadata()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testAudienceMismatchIsRejected() { + doNothing().when(provider).verifySignature(any(), any(), any()); + provider.validateAndExtractEmail(idToken(ISSUER, "another-client", "user@example.com", 3600), + registration, metadata()); + } + + @Test(expected = RuntimeException.class) + public void testExpiredTokenIsRejected() { + doNothing().when(provider).verifySignature(any(), any(), any()); + provider.validateAndExtractEmail(idToken(ISSUER, CLIENT_ID, "user@example.com", -3600), + registration, metadata()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testTokenWithoutEmailClaimIsRejected() { + doNothing().when(provider).verifySignature(any(), any(), any()); + provider.validateAndExtractEmail(idToken(ISSUER, CLIENT_ID, null, 3600), registration, metadata()); + } + + @Test + public void testValidTokenYieldsTheEmailClaim() { + doNothing().when(provider).verifySignature(any(), any(), any()); + + String email = provider.validateAndExtractEmail(idToken(ISSUER, CLIENT_ID, "user@example.com", 3600), + registration, metadata()); + + assertEquals("user@example.com", email); + } + + /** + * The provider is a singleton shared by every login, so it must hold no token state between + * calls: each call has to exchange the authorization code it was given. + */ + @Test + public void testEveryCallExchangesItsOwnAuthorizationCode() { + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration); + doReturn(metadata()).when(provider).getMetadata(registration); + doReturn("token-for-first").when(provider).exchangeAuthorizationCode(eq("first-code"), any(), any()); + doReturn("token-for-second").when(provider).exchangeAuthorizationCode(eq("second-code"), any(), any()); + doReturn("first@example.com").when(provider).validateAndExtractEmail(eq("token-for-first"), any(), any()); + doReturn("second@example.com").when(provider).validateAndExtractEmail(eq("token-for-second"), any(), any()); + + assertEquals("first@example.com", provider.verifySecretCodeAndFetchEmail("first-code", null, REGISTRATION)); + assertEquals("second@example.com", provider.verifySecretCodeAndFetchEmail("second-code", null, REGISTRATION)); + + verify(provider, times(1)).exchangeAuthorizationCode(eq("first-code"), any(), any()); + verify(provider, times(1)).exchangeAuthorizationCode(eq("second-code"), any(), any()); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserRejectsAnEmailThatDoesNotMatchTheToken() { + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration); + doReturn("someone-else@example.com").when(provider).resolveEmail("code", null, REGISTRATION); + + provider.verifyUser("user@example.com", "code", null, REGISTRATION); + } + + /** + * The UI resolves the code with verifyOAuthCodeAndGetUser and then logs in with the same code, and an + * identity provider accepts an authorization code only once, so the login must not redeem it again. + */ + @Test + public void testLoginAfterVerificationDoesNotRedeemTheCodeAgain() { + doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION); + + assertEquals("user@example.com", provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION)); + assertTrue(provider.verifyUser("user@example.com", "code", null, REGISTRATION)); + + verify(provider, times(1)).resolveEmail("code", null, REGISTRATION); + } + + @Test + public void testVerifiedCodeIsServedFromTheCacheOnlyOnce() { + doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION); + + provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION); + provider.verifyUser("user@example.com", "code", null, REGISTRATION); + provider.verifyUser("user@example.com", "code", null, REGISTRATION); + + verify(provider, times(2)).resolveEmail("code", null, REGISTRATION); + } + + @Test(expected = CloudRuntimeException.class) + public void testAnotherCodeIsNeverAnsweredFromTheCache() { + doReturn("user@example.com").when(provider).resolveEmail("user-code", null, REGISTRATION); + doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail("unrelated-code", null, REGISTRATION); + + provider.verifySecretCodeAndFetchEmail("user-code", null, REGISTRATION); + provider.verifyUser("user@example.com", "unrelated-code", null, REGISTRATION); + } + + @Test(expected = CloudRuntimeException.class) + public void testCachedCodeIsScopedToItsRegistration() { + doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION); + doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail("code", null, "other-idp"); + + provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION); + provider.verifyUser("user@example.com", "code", null, "other-idp"); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserRejectsEmptyArguments() { + provider.verifyUser("", "", null, REGISTRATION); + } +} diff --git a/ui/public/assets/oidc.svg b/ui/public/assets/oidc.svg new file mode 100644 index 000000000000..9be68623ee25 --- /dev/null +++ b/ui/public/assets/oidc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 99bf2cf7aef9..68a9d026b890 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1624,6 +1624,7 @@ "label.loginfo": "Log file information", "label.login.external": "External", "label.login.portal": "Portal login", +"label.login.with": "Sign in with {provider}", "label.login.single.signon": "Single sign-on", "label.logout": "Logout", "label.lun": "LUN", @@ -3907,6 +3908,7 @@ "message.new.version.available": "A new version of CloudStack is available. Click here to check the details", "message.no.data.to.show.for.period": "No data to show for the selected period.", "message.no.description": "No description entered.", +"message.oauth.provider.unreachable": "Unable to reach the OAuth provider to start the login.", "message.note.about.keypair.permissions.title": "Note about API key pair rule permissions", "message.note.about.keypair.permissions.body": "During the creation of API key pairs, it is possible to define a corresponding set of rule permissions. If a rule set is defined, the API key pair will only have access to APIs for which access has been explicitly granted (i.e., APIs whose corresponding rules are marked as allowed). On the other hand, if no rule set is specified, the API key pair permissions will follow and adapt to the permission set of the user's account role.", "message.offering.internet.protocol.warning": "WARNING: IPv6 supported Networks use static routing and will require upstream routes to be configured manually.", diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index 9dcc8b4f34c2..7713eb45b811 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -174,11 +174,11 @@
Enter your domain to see domain-specific providers
-
+
+
@@ -294,6 +304,7 @@ export default { oauthGithubRedirectUri: '', oauthKeycloakRedirectUri: '', oauthKeycloakAuthorizeUrl: '', + oauthGenericProviders: [], oauthLoading: false, oauthDomainQueried: false, loginType: 0, @@ -393,6 +404,7 @@ export default { getAPI('listOauthProvider', params).then(response => { if (response) { const oauthproviders = response.listoauthproviderresponse.oauthprovider || [] + this.oauthGenericProviders = oauthproviders.filter(item => item.type === 'oidc' && (item.enabled === true || item.enabled === 'true')) if (!domain) { oauthproviders.forEach(item => { if (item.provider === 'google') { @@ -510,6 +522,26 @@ export default { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'keycloak') }, + loginWithGenericOidc (provider) { + this.handleDomain() + this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', provider.provider) + const discoveryUrl = provider.issuerurl.replace(/\/$/, '') + '/.well-known/openid-configuration' + return fetch(discoveryUrl).then(response => response.json()).then(config => { + const options = { + client_id: provider.clientid, + redirect_uri: provider.redirecturi, + response_type: 'code', + scope: 'openid email', + state: this.from || 'cloudstack' + } + window.location.href = `${config.authorization_endpoint}?${new URLSearchParams(options).toString()}` + }).catch(() => { + this.$notification.error({ + message: this.$t('label.error'), + description: this.$t('message.oauth.provider.unreachable') + }) + }) + }, handleDomain () { const values = toRaw(this.form) const domain = this.customActiveKey === 'oauth' ? values.oauthDomain : values.domain