Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dfc45c0
Add domain_id to oauth_provider table and VO
Dec 18, 2025
fcb4778
Add domain-aware methods to OauthProviderDao
Dec 18, 2025
85c64e5
Add domainId parameter to OAuth provider API commands and response
Dec 18, 2025
c46fb98
Add domain support to OAuth2AuthManager
Dec 18, 2025
4ef100c
Add domain-aware OAuth verification
Dec 18, 2025
8e48938
Add domain support to ListOAuthProvidersCmd and update related tests
Dec 18, 2025
9ab26db
fix domain path issue
Dec 18, 2025
3e7fa92
Add domainId support to OAuth provider
Dec 18, 2025
4a6b28e
Return domain name and UUID in OAuth provider API responses using Api…
Damans227 Feb 24, 2026
3ab3ca9
Refactor domain ID resolution in VerifyOAuthCodeAndGetUserCmd to impr…
Damans227 Feb 24, 2026
bb6f137
Enhance OAuth2 plugin support for domain-level configuration and auth…
Damans227 Feb 24, 2026
764495b
Update OAuth2 tests and VerifyOAuthCodeAndGetUserCmdTest
Damans227 Feb 24, 2026
0ec6bcc
Add method to find OAuth provider by domain with global fallback
Damans227 Feb 24, 2026
9609ef0
Update OAuth provider configuration to use 'domain' instead of 'domai…
Damans227 Feb 24, 2026
65ccef1
Refactor OAuth provider methods to support domain-level queries and e…
Damans227 Feb 24, 2026
e3da679
Add caching for access token retrieval in GithubOAuth2Provider
Damans227 Feb 24, 2026
0e32a60
Refactor access token checks in GithubOAuth2Provider to use StringUti…
Damans227 Feb 24, 2026
23c4132
Refactor null checks to use utility for improved readability and cons…
Damans227 Feb 25, 2026
1a23f3a
Update OAuth2UserAuthenticatorTest to include domainId in user verifi…
Damans227 Feb 25, 2026
b8cf181
Merge branch 'main' into oauth-per-domain
Damans227 Feb 25, 2026
6d73964
Remove unnecessary blank line and unused imports in OAuth provider co…
Damans227 Feb 25, 2026
a428d1a
Refactor and cleanup
Damans227 Feb 25, 2026
b367434
Remove unnecessary blank lines
Damans227 Feb 25, 2026
42a8651
Enhance RegisterOAuthProviderCmdTest with additional provider mock data
Damans227 Feb 25, 2026
7a55ecb
Remove startup gate from OAuth plugin initialization to support dynam…
Damans227 Feb 25, 2026
57c837c
Add strictScope to ConfigKey to disable global fallback for domain-sc…
Damans227 Mar 3, 2026
62d4fc3
Add domain-scoped provider filtering to listOauthProvider and central…
Damans227 Mar 5, 2026
0bab9f6
Add External OAuth tab with domain-scoped provider selection to login…
Damans227 Mar 5, 2026
94c9e4b
code cleanup
Damans227 Mar 5, 2026
1a45270
test fix
Damans227 Mar 5, 2026
24ca473
Handle login page provider visibility
Damans227 Mar 6, 2026
df0ff42
UI cleanup
Damans227 Mar 6, 2026
0f06f99
UI Cleanup
Damans227 Mar 6, 2026
868b0f4
Keep text color consistent
Damans227 Mar 6, 2026
b4b7420
add unit tests
Damans227 Mar 11, 2026
dc30f14
Add Multiple-domain OAuth tests
Damans227 Mar 16, 2026
2f9001b
Refactor as per PR comments
Damans227 Mar 27, 2026
2185237
Use idempotent DDL helpers for oauth_provider schema migration
Damans227 Mar 27, 2026
2fe8d53
Use global config check for global providers and extract oauthEnabled…
Damans227 Mar 27, 2026
3b3288c
Make strictScope return null when id is null
Damans227 Mar 27, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ public interface UserOAuth2Authenticator extends Adapter {
*/
String verifyCodeAndFetchEmail(String secretCode);

/**
* Verifies if the logged in user is valid for a specific domain
* @return true if it's a valid user, otherwise false
*/
boolean verifyUser(String email, String secretCode, Long domainId);

/**
* Verifies the secret code provided by provider and fetches email for a specific domain
* @return email for the specified domain
*/
String verifyCodeAndFetchEmail(String secretCode, Long domainId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
String verifyCodeAndFetchEmail(String secretCode, Long domainId);
String verifySecretCodeAndFetchEmail(String secretCode, Long domainId);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sureshanaparti The domain-aware verifyCodeAndFetchEmail(String secretCode, Long domainId) is an overload of the pre-existing verifyCodeAndFetchEmail(String secretCode). If we rename only the new overload to verifySecretCodeAndFetchEmail, the two methods would have inconsistent names. Should we rename both for consistency?


/**
* Fetches email using the accessToken
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
-- Schema upgrade from 4.22.1.0 to 4.23.0.0
--;

CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider', 'domain_id', 'bigint unsigned DEFAULT NULL COMMENT "NULL for global provider, domain ID for domain-specific" AFTER `redirect_uri`');
CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.oauth_provider', 'fk_oauth_provider__domain_id', '(`domain_id`)', '`domain`(`id`)');
CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_oauth_provider__domain_id', 'cloud.oauth_provider', '(`domain_id`)');

CALL `cloud`.`IDEMPOTENT_ADD_UNIQUE_KEY`('cloud.oauth_provider', 'uk_oauth_provider__provider_domain', '(`provider`, `domain_id`)');

CREATE TABLE `cloud`.`backup_offering_details` (
`id` bigint unsigned NOT NULL auto_increment,
`backup_offering_id` bigint unsigned NOT NULL COMMENT 'Backup offering id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,18 @@ protected T valueInGlobalOrAvailableParentScope(Scope scope, Long id) {
}

public T valueInScope(Scope scope, Long id) {
return valueInScope(scope, id, false);
}

public T valueInScope(Scope scope, Long id, boolean strictScope) {
if (id == null) {
return value();
return strictScope ? null : value();
}
String value = s_depot != null ? s_depot.getConfigStringValue(_name, scope, id) : null;
if (value == null) {
if (strictScope) {
return null;
}
return valueInGlobalOrAvailableParentScope(scope, id);
}
logger.trace("Scope({}) value for config ({}): {}", scope, _name, _value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;

import java.util.List;
import java.util.Map;

public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableService {
String GLOBAL_DOMAIN_FILTER = "-1";
Long GLOBAL_DOMAIN_ID = -1L;

public static ConfigKey<Boolean> OAuth2IsPluginEnabled = new ConfigKey<Boolean>("Advanced", Boolean.class, "oauth2.enabled", "false",
"Indicates whether OAuth plugin is enabled or not", false);
"Indicates whether OAuth plugin is enabled or not. This can be configured at domain level.", true, ConfigKey.Scope.Domain);
public static final ConfigKey<String> OAuth2Plugins = new ConfigKey<String>("Advanced", String.class, "oauth2.plugins", "google,github",
"List of OAuth plugins", true);
public static final ConfigKey<String> OAuth2PluginsExclude = new ConfigKey<String>("Advanced", String.class, "oauth2.plugins.exclude", "",
Expand All @@ -49,13 +53,15 @@ public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableS
*/
UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName);

String verifyCodeAndFetchEmail(String code, String provider);
String verifyCodeAndFetchEmail(String code, String provider, Long domainId);

OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd);

List<OauthProviderVO> listOauthProviders(String provider, String uuid);
List<OauthProviderVO> listOauthProviders(String provider, String uuid, Long domainId);

boolean deleteOauthProvider(Long id);

OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd);

Long resolveDomainId(Map<String, Object[]> params);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
//
package org.apache.cloudstack.oauth2;

import com.cloud.domain.DomainVO;
import com.cloud.domain.dao.DomainDao;
import com.cloud.user.dao.UserDao;
import com.cloud.utils.component.Manager;
import com.cloud.utils.component.ManagerBase;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.framework.config.Configurable;
Expand All @@ -35,12 +38,15 @@
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import org.apache.commons.lang3.StringUtils;

import org.apache.commons.lang3.ArrayUtils;

import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthManager, Manager, Configurable {
@Inject
Expand All @@ -49,6 +55,9 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
@Inject
protected OauthProviderDao _oauthProviderDao;

@Inject
private DomainDao _domainDao;

protected static Map<String, UserOAuth2Authenticator> userOAuth2AuthenticationProvidersMap = new HashMap<>();

private List<UserOAuth2Authenticator> userOAuth2AuthenticationProviders;
Expand All @@ -64,17 +73,16 @@ public List<Class<?>> getAuthCommands() {

@Override
public boolean start() {
if (isOAuthPluginEnabled()) {
logger.info("OAUTH plugin loaded");
initializeUserOAuth2AuthenticationProvidersMap();
} else {
logger.info("OAUTH plugin not enabled so not loading");
}
initializeUserOAuth2AuthenticationProvidersMap();
logger.info("OAUTH plugin loaded");
return true;
}

protected boolean isOAuthPluginEnabled() {
return OAuth2IsPluginEnabled.value();
protected boolean isOAuthPluginEnabled(Long domainId) {
if (domainId == null) {
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.value());
}
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.valueInScope(ConfigKey.Scope.Domain, domainId, true));
}

@Override
Expand Down Expand Up @@ -125,9 +133,9 @@ protected void initializeUserOAuth2AuthenticationProvidersMap() {
}

@Override
public String verifyCodeAndFetchEmail(String code, String provider) {
public String verifyCodeAndFetchEmail(String code, String provider, Long domainId) {
UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider);
String email = authenticator.verifyCodeAndFetchEmail(code);
String email = authenticator.verifyCodeAndFetchEmail(code, domainId);

return email;
}
Expand All @@ -139,25 +147,36 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) {
String clientId = StringUtils.trim(cmd.getClientId());
String redirectUri = StringUtils.trim(cmd.getRedirectUri());
String secretKey = StringUtils.trim(cmd.getSecretKey());
Long domainId = cmd.getDomainId();

if (!isOAuthPluginEnabled()) {
if (!isOAuthPluginEnabled(domainId)) {
throw new CloudRuntimeException("OAuth is not enabled, please enable to register");
}
OauthProviderVO providerVO = _oauthProviderDao.findByProvider(provider);

// Check for existing provider with same name and domain
OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomain(provider, domainId);
if (providerVO != null) {
throw new CloudRuntimeException(String.format("Provider with the name %s is already registered", provider));
if (domainId == null) {
throw new CloudRuntimeException(String.format("Global provider with the name %s is already registered", provider));
} else {
throw new CloudRuntimeException(String.format("Provider with the name %s is already registered for domain %d", provider, domainId));
}
}

return saveOauthProvider(provider, description, clientId, secretKey, redirectUri);
return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, domainId);
}

@Override
public List<OauthProviderVO> listOauthProviders(String provider, String uuid) {
public List<OauthProviderVO> listOauthProviders(String provider, String uuid, Long domainId) {
List<OauthProviderVO> providers;
if (uuid != null) {
providers = Collections.singletonList(_oauthProviderDao.findByUuid(uuid));
} else if (StringUtils.isNotBlank(provider) && domainId != null) {
providers = Collections.singletonList(_oauthProviderDao.findByProviderAndDomain(provider, domainId));
} else if (StringUtils.isNotBlank(provider)) {
providers = Collections.singletonList(_oauthProviderDao.findByProvider(provider));
providers = Collections.singletonList(_oauthProviderDao.findByProviderAndDomain(provider, null));
} else if (domainId != null) {
providers = _oauthProviderDao.listByDomainIncludingGlobal(domainId);
} else {
providers = _oauthProviderDao.listAll();
}
Expand Down Expand Up @@ -199,14 +218,15 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) {
return _oauthProviderDao.findById(id);
}

private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri) {
private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, Long domainId) {
final OauthProviderVO oauthProviderVO = new OauthProviderVO();

oauthProviderVO.setProvider(provider);
oauthProviderVO.setDescription(description);
oauthProviderVO.setClientId(clientId);
oauthProviderVO.setSecretKey(secretKey);
oauthProviderVO.setRedirectUri(redirectUri);
oauthProviderVO.setDomainId(domainId);
oauthProviderVO.setEnabled(true);

_oauthProviderDao.persist(oauthProviderVO);
Expand All @@ -219,6 +239,38 @@ public boolean deleteOauthProvider(Long id) {
return _oauthProviderDao.remove(id);
}

@Override
public Long resolveDomainId(Map<String, Object[]> params) {
final String[] domainIdArray = (String[])params.get(ApiConstants.DOMAIN_ID);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API cmd uses domainId - ApiConstants.DOMAIN__ID (camelcase param - not deprecated yet)

@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING, description = "Path of the domain that the user belongs to. Example: domain=/com/cloud/internal. If no domain is passed in, the ROOT (/) domain is assumed.")
private String domain;
@Parameter(name = ApiConstants.DOMAIN__ID, type = CommandType.LONG, description = "The id of the domain that the user belongs to. If both domain and domainId are passed in, \"domainId\" parameter takes precedence.")
private Long domainId;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sureshanaparti Just looked into it, both getDomainIdFromParams and resolveDomainId look up ApiConstants.DOMAIN_ID ("domainid"). The API servlet lowercases param names, so DOMAIN__ID ("domainId") resolves correctly.

if (ArrayUtils.isNotEmpty(domainIdArray)) {
String domainUuid = domainIdArray[0];
if (GLOBAL_DOMAIN_FILTER.equals(domainUuid)) {
return GLOBAL_DOMAIN_ID;
}
DomainVO domain = _domainDao.findByUuid(domainUuid);
if (Objects.nonNull(domain)) {
return domain.getId();
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if you can use the below methods, and move get domain by path to a method (or can improve any existing method)

public String getDomainId(Map<String, Object[]> params) {

public Long fetchDomainId(final String domainUUID) {

Copy link
Copy Markdown
Collaborator Author

@Damans227 Damans227 Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sureshanaparti Looked into the ApiServer methods. fetchDomainId only resolves by UUID, while resolveDomainId also handles domain path resolution. The closest existing combination would be fetchDomainId + DomainService.findDomainByIdOrPath, which is what OauthLoginAPIAuthenticatorCmd already uses. I could refactor resolveDomainId to delegate to those, but since OAuth2AuthManagerImpl doesn't have access to ApiServer, it would mean injecting DomainService instead of DomainDao. Would you prefer that approach, or is the current implementation acceptable?

final String[] domainArray = (String[])params.get(ApiConstants.DOMAIN);
if (ArrayUtils.isNotEmpty(domainArray)) {
String path = domainArray[0];
if (StringUtils.isNotEmpty(path)) {
if (!path.startsWith("/")) {
path = "/" + path;
}
if (!path.endsWith("/")) {
path += "/";
}
DomainVO domain = _domainDao.findDomainByPath(path);
if (Objects.nonNull(domain)) {
return domain.getId();
}
}
}
return null;
}
Comment on lines +243 to +272
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new resolveDomainId logic adds non-trivial behavior (UUID lookup, special -1 handling, and domain-path normalization). Adding unit tests for these branches would help prevent regressions (e.g., path formatting and the -1 global filter behavior).

Copilot uses AI. Check for mistakes.

@Override
public String getConfigComponentName() {
return "OAUTH2-PLUGIN";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.auth.UserAuthenticator;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import org.apache.cloudstack.framework.config.ConfigKey;

import javax.inject.Inject;
import java.util.Map;
import java.util.Objects;

import static org.apache.cloudstack.oauth2.OAuth2AuthManager.OAuth2IsPluginEnabled;

Expand All @@ -49,7 +51,7 @@ public Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username,
logger.debug("Trying OAuth2 auth for user: " + username);
}

if (!isOAuthPluginEnabled()) {
if (!isOAuthPluginEnabled(domainId)) {
logger.debug("OAuth2 plugin is disabled");
return new Pair<Boolean, ActionOnFailedAuthentication>(false, null);
} else if (requestParameters == null) {
Expand All @@ -76,7 +78,7 @@ public Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username,
String secretCode = ((secretCodeArray == null) ? null : secretCodeArray[0]);

UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider);
if (user != null && authenticator.verifyUser(email, secretCode)) {
if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId)) {
return new Pair<Boolean, ActionOnFailedAuthentication>(true, null);
}
}
Expand All @@ -89,7 +91,10 @@ public String encode(String password) {
return null;
}

protected boolean isOAuthPluginEnabled() {
return OAuth2IsPluginEnabled.value();
protected boolean isOAuthPluginEnabled(Long domainId) {
if (domainId == null) {
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.value());
}
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.valueInScope(ConfigKey.Scope.Domain, domainId, true));
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing strictScope = true disables fallback to parent/global scope (it returns null when no domain-scoped value exists). That contradicts the PR goal of 'global fallback' and can unexpectedly disable OAuth in domains that rely on global config. Consider using the default valueInScope(scope, id) (or strictScope = false) for enablement checks where fallback is desired.

Suggested change
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.valueInScope(ConfigKey.Scope.Domain, domainId, true));
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.valueInScope(ConfigKey.Scope.Domain, domainId));

Copilot uses AI. Check for mistakes.
}
}
Loading
Loading