Copilot commented on code in PR #14205:
URL: https://github.com/apache/cloudstack/pull/14205#discussion_r4250005047
##########
ui/src/views/auth/Login.vue:
##########
@@ -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(() => {
Review Comment:
The `state` parameter is predictable (`this.from || 'cloudstack'`) and
doesn’t appear to be a cryptographically random, session-bound CSRF token. For
OAuth/OIDC authorization-code flows this should be an unguessable value stored
client-side (e.g., sessionStorage) and validated on callback; otherwise the
flow is vulnerable to login CSRF / mix-up attacks.
##########
plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java:
##########
@@ -0,0 +1,417 @@
+//
+// 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.framework.config.ConfigKey;
+import org.apache.cloudstack.oauth2.OAuth2AuthManager;
+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.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.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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+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<String, OIDCMetadata> metadataCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ private final Cache<String, String> verifiedEmailCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(VERIFIED_EMAIL_CACHE_SECONDS,
TimeUnit.SECONDS)
+ .maximumSize(1024)
+ .build();
+
+ private final Cache<String, JsonWebKeys> jwksCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ protected Logger logger = LogManager.getLogger(getClass());
+
+ 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");
+ }
+
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String verifiedEmail =
verifiedEmailCache.asMap().remove(verifiedEmailKey(registration, secretCode));
+ if (verifiedEmail == null) {
+ verifiedEmail = resolveEmail(registration, secretCode);
+ }
+ 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) {
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String email = resolveEmail(registration, secretCode);
+ verifiedEmailCache.put(verifiedEmailKey(registration, secretCode),
email);
+ return email;
+ }
+
+ protected String resolveEmail(OauthProviderVO registration, String
secretCode) {
+ OIDCMetadata metadata = getMetadata(registration);
+ String idToken = exchangeAuthorizationCode(secretCode, registration,
metadata);
+
+ return validateAndExtractEmail(idToken, registration, metadata);
+ }
+
+ @Override
+ public String getUserEmailAddress() throws CloudRuntimeException {
+ return null;
+ }
+
+ private String verifiedEmailKey(OauthProviderVO registration, String
secretCode) {
+ return DigestUtils.sha256Hex(registration.getUuid() + ":" +
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<NameValuePair> 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) {
+ logger.warn("{} returned an error during the token exchange:
{}", provider.getProvider(), body);
+ throw new CloudRuntimeException(String.format("The %s identity
provider rejected the token exchange", provider.getProvider()));
+ }
Review Comment:
Logging the full token endpoint response body can leak sensitive data (e.g.,
`id_token`, `access_token`, error details) into server logs. Recommend removing
the body from logs or logging only a redacted/whitelisted subset (e.g., HTTP
status + `error`/`error_description` capped to a safe length).
##########
ui/src/views/auth/Login.vue:
##########
@@ -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')
+ })
+ })
Review Comment:
`fetch(...).then(response => response.json())` will attempt to parse JSON
even when the response is non-2xx (HTML error pages, 404, etc.), and the code
assumes `config.authorization_endpoint` always exists. Consider checking
`response.ok` and validating `config.authorization_endpoint` before
redirecting; otherwise the user may see a generic error despite a
recoverable/diagnosable failure mode.
##########
plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java:
##########
@@ -0,0 +1,417 @@
+//
+// 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.framework.config.ConfigKey;
+import org.apache.cloudstack.oauth2.OAuth2AuthManager;
+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.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.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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+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<String, OIDCMetadata> metadataCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ private final Cache<String, String> verifiedEmailCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(VERIFIED_EMAIL_CACHE_SECONDS,
TimeUnit.SECONDS)
+ .maximumSize(1024)
+ .build();
+
+ private final Cache<String, JsonWebKeys> jwksCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ protected Logger logger = LogManager.getLogger(getClass());
+
+ 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");
+ }
+
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String verifiedEmail =
verifiedEmailCache.asMap().remove(verifiedEmailKey(registration, secretCode));
+ if (verifiedEmail == null) {
+ verifiedEmail = resolveEmail(registration, secretCode);
+ }
+ 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) {
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String email = resolveEmail(registration, secretCode);
+ verifiedEmailCache.put(verifiedEmailKey(registration, secretCode),
email);
+ return email;
+ }
+
+ protected String resolveEmail(OauthProviderVO registration, String
secretCode) {
+ OIDCMetadata metadata = getMetadata(registration);
+ String idToken = exchangeAuthorizationCode(secretCode, registration,
metadata);
+
+ return validateAndExtractEmail(idToken, registration, metadata);
+ }
+
+ @Override
+ public String getUserEmailAddress() throws CloudRuntimeException {
+ return null;
+ }
+
+ private String verifiedEmailKey(OauthProviderVO registration, String
secretCode) {
+ return DigestUtils.sha256Hex(registration.getUuid() + ":" +
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);
+ }
Review Comment:
The cache key uses the raw trimmed issuer URL, so equivalent values like
`https://idp.example.com` vs `https://idp.example.com/` will create separate
cache entries and repeat discovery/JWKS fetches. Normalize the issuer URL first
(e.g., remove trailing slash) before using it as the cache key to improve cache
hit rate and reduce outbound calls.
##########
plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java:
##########
@@ -0,0 +1,417 @@
+//
+// 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.framework.config.ConfigKey;
+import org.apache.cloudstack.oauth2.OAuth2AuthManager;
+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.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.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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+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<String, OIDCMetadata> metadataCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ private final Cache<String, String> verifiedEmailCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(VERIFIED_EMAIL_CACHE_SECONDS,
TimeUnit.SECONDS)
+ .maximumSize(1024)
+ .build();
+
+ private final Cache<String, JsonWebKeys> jwksCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ protected Logger logger = LogManager.getLogger(getClass());
+
+ 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");
+ }
+
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String verifiedEmail =
verifiedEmailCache.asMap().remove(verifiedEmailKey(registration, secretCode));
+ if (verifiedEmail == null) {
+ verifiedEmail = resolveEmail(registration, secretCode);
+ }
+ 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) {
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String email = resolveEmail(registration, secretCode);
+ verifiedEmailCache.put(verifiedEmailKey(registration, secretCode),
email);
+ return email;
+ }
+
+ protected String resolveEmail(OauthProviderVO registration, String
secretCode) {
+ OIDCMetadata metadata = getMetadata(registration);
+ String idToken = exchangeAuthorizationCode(secretCode, registration,
metadata);
+
+ return validateAndExtractEmail(idToken, registration, metadata);
+ }
+
+ @Override
+ public String getUserEmailAddress() throws CloudRuntimeException {
+ return null;
+ }
+
+ private String verifiedEmailKey(OauthProviderVO registration, String
secretCode) {
+ return DigestUtils.sha256Hex(registration.getUuid() + ":" +
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<NameValuePair> 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) {
+ logger.warn("{} returned an error during the token exchange:
{}", provider.getProvider(), body);
+ throw new CloudRuntimeException(String.format("The %s identity
provider rejected the token exchange", provider.getProvider()));
+ }
+
+ 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");
+ }
+ Boolean emailVerified = emailVerifiedClaim(claims);
+ if (Boolean.FALSE.equals(emailVerified)) {
+ throw new CloudAuthenticationException("The identity provider has
marked the email address in the id_token as not verified");
+ }
+ if (emailVerified == null && isVerifiedEmailRequired(provider)) {
+ throw new CloudAuthenticationException(String.format(
+ "The identity provider did not assert that the email
address is verified; set %s to false for this domain to accept it",
+ OAuth2AuthManager.OIDCRequireVerifiedEmail.key()));
+ }
+ return email;
+ }
+
+ private Boolean emailVerifiedClaim(JwtClaims claims) {
+ Object verified = claims.getClaim("email_verified");
+ if (verified instanceof Boolean) {
+ return (Boolean) verified;
+ }
+ if (verified instanceof String) {
+ String value = ((String) verified).trim();
+ if ("true".equalsIgnoreCase(value)) {
+ return Boolean.TRUE;
+ }
+ if ("false".equalsIgnoreCase(value)) {
+ return Boolean.FALSE;
+ }
+ }
+ return null;
+ }
+
+ protected boolean isVerifiedEmailRequired(OauthProviderVO provider) {
+ return Boolean.TRUE.equals(
+
OAuth2AuthManager.OIDCRequireVerifiedEmail.valueInScope(ConfigKey.Scope.Domain,
provider.getDomainId(), false));
+ }
+
+ 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()));
+ }
+
+ String jwksUri = metadata.getJwksUri();
+ String keyId = consumer.getJwsHeaders().getKeyId();
+
+ JsonWebKeys keys = jwksCache.get(jwksUri, this::readJwkSet);
+ JsonWebKey key = selectKey(keys, keyId);
+ if (key == null && StringUtils.isNotBlank(keyId)) {
+ keys = readJwkSet(jwksUri);
+ jwksCache.put(jwksUri, keys);
+ key = selectKey(keys, keyId);
+ }
+ if (key == null) {
+ throw new CloudAuthenticationException("No matching signing key
was published by the identity provider");
+ }
+ if (!consumer.verifySignatureWith(key, resolveAlgorithm(consumer,
key))) {
+ throw new CloudAuthenticationException("The id_token signature is
not valid");
+ }
+ }
+
+ private JsonWebKey selectKey(JsonWebKeys keys, String keyId) {
+ return StringUtils.isNotBlank(keyId) ? keys.getKey(keyId) :
singleKey(keys);
+ }
+
+ private SignatureAlgorithm resolveAlgorithm(JwsJwtCompactConsumer
consumer, JsonWebKey key) {
+ SignatureAlgorithm algorithm = key.getAlgorithm() != null
+ ? SignatureAlgorithm.getAlgorithm(key.getAlgorithm())
+ : consumer.getJwsHeaders().getSignatureAlgorithm();
+ if (algorithm == null) {
+ throw new CloudAuthenticationException("The id_token does not name
a signature algorithm");
+ }
+ return algorithm;
+ }
+
+ 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<JsonWebKey> 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) {
+ logger.warn("{} (HTTP {}): {}", failureMessage,
response.getStatusLine().getStatusCode(), body);
+ throw new CloudRuntimeException(failureMessage);
+ }
Review Comment:
Similar to the token-exchange path, logging the full response body on
non-200 can leak sensitive content returned by the IdP (even if it’s not
included in the thrown exception). Consider omitting the body entirely or
truncating + redacting it in logs to reduce risk of credential/token disclosure.
##########
plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java:
##########
@@ -0,0 +1,417 @@
+//
+// 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.framework.config.ConfigKey;
+import org.apache.cloudstack.oauth2.OAuth2AuthManager;
+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.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.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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+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<String, OIDCMetadata> metadataCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ private final Cache<String, String> verifiedEmailCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(VERIFIED_EMAIL_CACHE_SECONDS,
TimeUnit.SECONDS)
+ .maximumSize(1024)
+ .build();
+
+ private final Cache<String, JsonWebKeys> jwksCache =
+ Caffeine.newBuilder()
+ .expireAfterWrite(METADATA_CACHE_MINUTES, TimeUnit.MINUTES)
+ .maximumSize(64)
+ .build();
+
+ protected Logger logger = LogManager.getLogger(getClass());
+
+ 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");
+ }
+
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String verifiedEmail =
verifiedEmailCache.asMap().remove(verifiedEmailKey(registration, secretCode));
+ if (verifiedEmail == null) {
+ verifiedEmail = resolveEmail(registration, secretCode);
+ }
+ 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) {
+ OauthProviderVO registration = findRegistration(providerName,
domainId);
+ String email = resolveEmail(registration, secretCode);
+ verifiedEmailCache.put(verifiedEmailKey(registration, secretCode),
email);
+ return email;
+ }
+
+ protected String resolveEmail(OauthProviderVO registration, String
secretCode) {
+ OIDCMetadata metadata = getMetadata(registration);
+ String idToken = exchangeAuthorizationCode(secretCode, registration,
metadata);
+
+ return validateAndExtractEmail(idToken, registration, metadata);
+ }
+
+ @Override
+ public String getUserEmailAddress() throws CloudRuntimeException {
+ return null;
+ }
+
+ private String verifiedEmailKey(OauthProviderVO registration, String
secretCode) {
+ return DigestUtils.sha256Hex(registration.getUuid() + ":" +
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<NameValuePair> 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) {
+ logger.warn("{} returned an error during the token exchange:
{}", provider.getProvider(), body);
+ throw new CloudRuntimeException(String.format("The %s identity
provider rejected the token exchange", provider.getProvider()));
+ }
+
+ 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())) {
Review Comment:
`claims.getAudiences()` can be null (or empty) for malformed/partial tokens;
calling `.contains(...)` would then throw a `NullPointerException` instead of
producing a controlled auth failure. Recommend explicitly handling
missing/empty audiences and raising a `CloudAuthenticationException` with a
clear message.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]