Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
oauth2: add a generic OIDC provider type
The OAuth2 plugin resolves a login to a UserOAuth2Authenticator through a fixed
provider-name to Spring-bean map, and each OIDC vendor is its own bean running the
same authorization-code flow with no vendor-specific logic. A new IdP needs a new
class, and since provider is both the display name and the routing key, a domain can
register only one keycloak.

This adds a type column to oauth_provider. OAuth2AuthManagerImpl looks the name up in
the bean map first and only on a miss falls back to the registration's type, so
provider becomes an admin-chosen label and type selects the implementation. One bean
then serves any number of registrations under arbitrary names. Existing google, github
and keycloak rows carry a null type and dispatch by name as before.

GenericOIDCOAuth2Provider is registered under type oidc and configured with the issuer
URL. It reads the token and JWKS endpoints from the issuer's discovery document (the
issuer must match), and validates the id_token before trusting it: signature against the
JWKS key named by the token kid, then issuer, audience and expiry, using the CXF JOSE
library already on the classpath. It holds no token state between logins, and a code
verified through verifyOAuthCodeAndGetUser is redeemed once so the following oauthlogin
does not re-present it.

ListOAuthProvidersCmd and UpdateOAuthProviderCmd derived the response enabled flag from a
name-to-bean check, which reported every generically-named registration as disabled; both
now also accept a registration whose type resolves to a plugin.

The schema change adds type and issuer_url to oauth_provider in the 4.23.0.0 to 24.0.0
upgrade file. Login.vue renders the OAuth buttons from the registered provider list so a
generic oidc provider gets a Sign in with <label> button and starts the flow from the
issuer's authorize endpoint, resolved from discovery.
  • Loading branch information
nagaboinaramgopal committed Oct 1, 2026
commit f98eb11f6b18749dae8522cd9aa97db6721d9f1c
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` ');
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,30 @@ public List<UserOAuth2Authenticator> 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<UserOAuth2Authenticator> getUserOAuth2AuthenticationProviders() {
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ public Pair<Boolean, ActionOnFailedAuthentication> 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<Boolean, ActionOnFailedAuthentication>(true, null);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -184,8 +185,10 @@ public String authenticate(String command, Map<String, Object[]> 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);
Expand All @@ -200,7 +203,7 @@ public String authenticate(String command, Map<String, Object[]> params, HttpSes
for (OauthProviderVO domainProvider : allProviders) {
if (domainProvider.getDomainId() != null && domainProvider.isEnabled()
&& OAuth2AuthManager.isPluginEnabledForDomain(domainProvider.getDomainId())
&& authenticatorPluginNames.contains(domainProvider.getProvider())) {
&& isServedByPlugin(domainProvider, authenticatorPluginNames)) {
totalEnabledCount++;
}
}
Expand All @@ -214,6 +217,11 @@ public String authenticate(String command, Map<String, Object[]> params, HttpSes
return ApiResponseSerializer.toSerializedString(response, responseType);
}

protected boolean isServedByPlugin(OauthProviderVO provider, List<String> authenticatorPluginNames) {
return authenticatorPluginNames.contains(provider.getProvider())
|| (StringUtils.isNotBlank(provider.getType()) && authenticatorPluginNames.contains(provider.getType()));
}

@Override
public APIAuthenticationType getAPIType() {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -113,6 +118,10 @@ public String getTokenUrl() {
return tokenUrl;
}

public String getIssuerUrl() {
return issuerUrl;
}

public Boolean getEnabled() {
return enabled;
}
Expand Down Expand Up @@ -152,15 +161,19 @@ 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<UserOAuth2Authenticator> userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders();
List<String> authenticatorPluginNames = new ArrayList<>();
for (UserOAuth2Authenticator authenticator : userOAuth2AuthenticatorPlugins) {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading