Skip to content

Commit f98eb11

Browse files
committed
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.
1 parent 1a48a87 commit f98eb11

19 files changed

Lines changed: 1003 additions & 24 deletions

File tree

‎api/src/main/java/org/apache/cloudstack/api/ApiConstants.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,7 @@ public class ApiConstants {
13631363
public static final String CLIENT_ID = "clientid";
13641364
public static final String REDIRECT_URI = "redirecturi";
13651365
public static final String TOKEN_URL = "tokenurl";
1366+
public static final String ISSUER_URL = "issuerurl";
13661367

13671368
public static final String IS_TAG_A_RULE = "istagarule";
13681369

‎api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ public interface UserOAuth2Authenticator extends Adapter {
5656
*/
5757
String verifySecretCodeAndFetchEmail(String secretCode, Long domainId);
5858

59+
/**
60+
* Verifies the user against the registration identified by providerName. Implementations that
61+
* serve a single registration ignore the name; implementations shared by several registrations
62+
* use it to select the one to authenticate against.
63+
* @return true if it's a valid user, otherwise false
64+
*/
65+
default boolean verifyUser(String email, String secretCode, Long domainId, String providerName) {
66+
return verifyUser(email, secretCode, domainId);
67+
}
68+
69+
/**
70+
* Verifies the secret code against the registration identified by providerName and fetches email.
71+
* @return email for the specified registration
72+
*/
73+
default String verifySecretCodeAndFetchEmail(String secretCode, Long domainId, String providerName) {
74+
return verifySecretCodeAndFetchEmail(secretCode, domainId);
75+
}
76+
5977
/**
6078
* Fetches email using the accessToken
6179
* @return returns email

‎engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@
1818
--;
1919
-- Schema upgrade from 4.23.0.0 to 24.0.0
2020
--;
21+
22+
-- Generic OIDC OAuth2 provider: a type to select the implementation, and the issuer URL for discovery
23+
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` ');
24+
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` ');

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManager.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableS
5555
*/
5656
UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName);
5757

58+
/**
59+
* Finds the user OAuth2 provider serving the named registration within a domain scope. A name
60+
* that matches no provider plugin is resolved through the type of its registration, so that a
61+
* generic provider can serve registrations under administrator chosen names.
62+
* @param providerName name of the registration
63+
* @param domainId domain id, or null for global
64+
* @return OAuth2 provider
65+
*/
66+
UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName, final Long domainId);
67+
5868
String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId);
5969

6070
OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd);

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,30 @@ public List<UserOAuth2Authenticator> listUserOAuth2AuthenticationProviders() {
125125

126126
@Override
127127
public UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(String providerName) {
128+
return getUserOAuth2AuthenticationProvider(providerName, null);
129+
}
130+
131+
@Override
132+
public UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(String providerName, Long domainId) {
128133
if (StringUtils.isEmpty(providerName)) {
129134
throw new CloudRuntimeException("OAuth2 authentication provider name is empty");
130135
}
131-
if (!userOAuth2AuthenticationProvidersMap.containsKey(providerName.toLowerCase())) {
136+
UserOAuth2Authenticator authenticator = userOAuth2AuthenticationProvidersMap.get(providerName.toLowerCase());
137+
if (authenticator == null) {
138+
authenticator = findAuthenticatorByRegisteredType(providerName, domainId);
139+
}
140+
if (authenticator == null) {
132141
throw new CloudRuntimeException(String.format("Failed to find OAuth2 authentication provider by the name: %s.", providerName));
133142
}
134-
return userOAuth2AuthenticationProvidersMap.get(providerName.toLowerCase());
143+
return authenticator;
144+
}
145+
146+
protected UserOAuth2Authenticator findAuthenticatorByRegisteredType(String providerName, Long domainId) {
147+
OauthProviderVO registration = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(providerName, domainId);
148+
if (registration == null || StringUtils.isBlank(registration.getType())) {
149+
return null;
150+
}
151+
return userOAuth2AuthenticationProvidersMap.get(registration.getType().toLowerCase());
135152
}
136153

137154
public List<UserOAuth2Authenticator> getUserOAuth2AuthenticationProviders() {
@@ -152,8 +169,8 @@ protected void initializeUserOAuth2AuthenticationProvidersMap() {
152169

153170
@Override
154171
public String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId) {
155-
UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider);
156-
String email = authenticator.verifySecretCodeAndFetchEmail(code, domainId);
172+
UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider, domainId);
173+
String email = authenticator.verifySecretCodeAndFetchEmail(code, domainId, provider);
157174

158175
return email;
159176
}
@@ -168,11 +185,17 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) {
168185
Long domainId = normalizeGlobalScope(resolveDomainIdFromIdOrPath(cmd.getDomainId(), cmd.getDomainPath()));
169186
String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl());
170187
String tokenUrl = StringUtils.trim(cmd.getTokenUrl());
188+
String type = StringUtils.trim(cmd.getType());
189+
String issuerUrl = StringUtils.trim(cmd.getIssuerUrl());
171190

172191
if (!isOAuthPluginEnabled(domainId)) {
173192
throw new CloudRuntimeException("OAuth is not enabled, please enable to register");
174193
}
175194

195+
if (StringUtils.isNotBlank(type) && !userOAuth2AuthenticationProvidersMap.containsKey(type.toLowerCase())) {
196+
throw new CloudRuntimeException(String.format("No OAuth2 provider plugin is available for the type %s", type));
197+
}
198+
176199
// Check for existing provider with same name and domain
177200
OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomain(provider, domainId);
178201
if (providerVO != null) {
@@ -183,7 +206,7 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) {
183206
}
184207
}
185208

186-
return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl, domainId);
209+
return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl, domainId, type, issuerUrl);
187210
}
188211

189212
@Override
@@ -212,6 +235,7 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) {
212235
String secretKey = StringUtils.trim(cmd.getSecretKey());
213236
String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl());
214237
String tokenUrl = StringUtils.trim(cmd.getTokenUrl());
238+
String issuerUrl = StringUtils.trim(cmd.getIssuerUrl());
215239
Boolean enabled = cmd.getEnabled();
216240

217241
OauthProviderVO providerVO = _oauthProviderDao.findById(id);
@@ -261,6 +285,9 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) {
261285
if (StringUtils.isNotEmpty(tokenUrl)) {
262286
providerVO.setTokenUrl(tokenUrl);
263287
}
288+
if (StringUtils.isNotEmpty(issuerUrl)) {
289+
providerVO.setIssuerUrl(issuerUrl);
290+
}
264291
if (enabled != null) {
265292
providerVO.setEnabled(enabled);
266293
}
@@ -271,7 +298,8 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) {
271298
return _oauthProviderDao.findById(id);
272299
}
273300

274-
private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl, Long domainId) {
301+
private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl,
302+
String tokenUrl, Long domainId, String type, String issuerUrl) {
275303
final OauthProviderVO oauthProviderVO = new OauthProviderVO();
276304

277305
oauthProviderVO.setProvider(provider);
@@ -282,6 +310,8 @@ private OauthProviderVO saveOauthProvider(String provider, String description, S
282310
oauthProviderVO.setDomainId(domainId);
283311
oauthProviderVO.setAuthorizeUrl(authorizeUrl);
284312
oauthProviderVO.setTokenUrl(tokenUrl);
313+
oauthProviderVO.setType(type);
314+
oauthProviderVO.setIssuerUrl(issuerUrl);
285315
oauthProviderVO.setEnabled(true);
286316

287317
_oauthProviderDao.persist(oauthProviderVO);

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2UserAuthenticator.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ public Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username,
7474
String email = ((emailArray == null) ? null : emailArray[0]);
7575
String secretCode = ((secretCodeArray == null) ? null : secretCodeArray[0]);
7676

77-
UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider);
78-
if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId)) {
77+
UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider, domainId);
78+
if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId, oauthProvider)) {
7979
return new Pair<Boolean, ActionOnFailedAuthentication>(true, null);
8080
}
8181
}

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.apache.commons.lang.ArrayUtils;
4747
import org.apache.commons.lang3.EnumUtils;
4848
import org.apache.commons.lang3.ObjectUtils;
49+
import org.apache.commons.lang3.StringUtils;
4950

5051
import com.cloud.api.ApiDBUtils;
5152
import com.cloud.api.ApiServer;
@@ -184,8 +185,10 @@ public String authenticate(String command, Map<String, Object[]> params, HttpSes
184185
OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(),
185186
result.getDescription(), result.getClientId(), secretKeyAllowed ? result.getSecretKey() : null,
186187
result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl(), domain);
188+
r.setType(result.getType());
189+
r.setIssuerUrl(result.getIssuerUrl());
187190
boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId());
188-
if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
191+
if (oauthEnabled && isServedByPlugin(result, authenticatorPluginNames) && result.isEnabled()) {
189192
r.setEnabled(true);
190193
} else {
191194
r.setEnabled(false);
@@ -200,7 +203,7 @@ public String authenticate(String command, Map<String, Object[]> params, HttpSes
200203
for (OauthProviderVO domainProvider : allProviders) {
201204
if (domainProvider.getDomainId() != null && domainProvider.isEnabled()
202205
&& OAuth2AuthManager.isPluginEnabledForDomain(domainProvider.getDomainId())
203-
&& authenticatorPluginNames.contains(domainProvider.getProvider())) {
206+
&& isServedByPlugin(domainProvider, authenticatorPluginNames)) {
204207
totalEnabledCount++;
205208
}
206209
}
@@ -214,6 +217,11 @@ public String authenticate(String command, Map<String, Object[]> params, HttpSes
214217
return ApiResponseSerializer.toSerializedString(response, responseType);
215218
}
216219

220+
protected boolean isServedByPlugin(OauthProviderVO provider, List<String> authenticatorPluginNames) {
221+
return authenticatorPluginNames.contains(provider.getProvider())
222+
|| (StringUtils.isNotBlank(provider.getType()) && authenticatorPluginNames.contains(provider.getType()));
223+
}
224+
217225
@Override
218226
public APIAuthenticationType getAPIType() {
219227
return null;

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
3333
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
3434
import org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider;
35+
import org.apache.cloudstack.oauth2.oidc.GenericOIDCOAuth2Provider;
3536
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
3637
import org.apache.commons.collections.MapUtils;
3738
import org.apache.commons.lang3.StringUtils;
@@ -76,6 +77,16 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
7677
@Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)", since = "4.23.0")
7778
private String tokenUrl;
7879

80+
@Parameter(name = ApiConstants.TYPE, type = CommandType.STRING,
81+
description = "Type of the provider implementation serving this registration, for example oidc for any OpenID Connect compliant provider. "
82+
+ "When set, the name in provider is a label chosen by the administrator rather than a built in provider name.", since = "4.24.0")
83+
private String type;
84+
85+
@Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING,
86+
description = "Issuer URL of the OpenID Connect provider, required for type oidc. The token endpoint and the keys that sign "
87+
+ "its tokens are read from the issuer's discovery document", since = "4.24.0")
88+
private String issuerUrl;
89+
7990
@Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP,
8091
description = "Any OAuth provider details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].clientsecret=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")
8192
protected Map details;
@@ -121,6 +132,14 @@ public String getTokenUrl() {
121132
return tokenUrl;
122133
}
123134

135+
public String getType() {
136+
return type;
137+
}
138+
139+
public String getIssuerUrl() {
140+
return issuerUrl;
141+
}
142+
124143
public Map getDetails() {
125144
if (MapUtils.isEmpty(details)) {
126145
return null;
@@ -143,12 +162,18 @@ public void execute() throws ServerApiException, ConcurrentOperationException, E
143162
}
144163
}
145164

165+
if (StringUtils.equalsIgnoreCase(GenericOIDCOAuth2Provider.OIDC_PROVIDER_TYPE, getType()) && StringUtils.isBlank(getIssuerUrl())) {
166+
throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter issuerurl is mandatory for an oidc OAuth provider");
167+
}
168+
146169
OauthProviderVO provider = _oauth2mgr.registerOauthProvider(this);
147170

148171
Domain domain = provider.getDomainId() != null ? ApiDBUtils.findDomainById(provider.getDomainId()) : null;
149172
OauthProviderResponse response = new OauthProviderResponse(provider.getUuid(), provider.getProvider(),
150173
provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri(),
151174
provider.getAuthorizeUrl(), provider.getTokenUrl(), domain);
175+
response.setType(provider.getType());
176+
response.setIssuerUrl(provider.getIssuerUrl());
152177
response.setResponseName(getCommandName());
153178
response.setObjectName(ApiConstants.OAUTH_PROVIDER);
154179
setResponseObject(response);

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
3535
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
3636
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
37+
import org.apache.commons.lang3.StringUtils;
3738

3839
import com.cloud.api.ApiDBUtils;
3940
import com.cloud.domain.Domain;
@@ -67,6 +68,10 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
6768
@Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL pre-registered in the specific OAuth provider", since = "4.23.0")
6869
private String tokenUrl;
6970

71+
@Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING,
72+
description = "Issuer URL of the OpenID Connect provider, used to read its discovery document", since = "4.24.0")
73+
private String issuerUrl;
74+
7075
@Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value")
7176
private Boolean enabled;
7277

@@ -113,6 +118,10 @@ public String getTokenUrl() {
113118
return tokenUrl;
114119
}
115120

121+
public String getIssuerUrl() {
122+
return issuerUrl;
123+
}
124+
116125
public Boolean getEnabled() {
117126
return enabled;
118127
}
@@ -152,15 +161,19 @@ public void execute() {
152161
OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(),
153162
result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(),
154163
result.getAuthorizeUrl(), result.getTokenUrl(), domain);
164+
r.setType(result.getType());
165+
r.setIssuerUrl(result.getIssuerUrl());
155166

156167
List<UserOAuth2Authenticator> userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders();
157168
List<String> authenticatorPluginNames = new ArrayList<>();
158169
for (UserOAuth2Authenticator authenticator : userOAuth2AuthenticatorPlugins) {
159170
String name = authenticator.getName();
160171
authenticatorPluginNames.add(name);
161172
}
173+
boolean servedByPlugin = authenticatorPluginNames.contains(result.getProvider())
174+
|| (StringUtils.isNotBlank(result.getType()) && authenticatorPluginNames.contains(result.getType()));
162175
boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId());
163-
if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
176+
if (oauthEnabled && servedByPlugin && result.isEnabled()) {
164177
r.setEnabled(true);
165178
} else {
166179
r.setEnabled(false);

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ public class OauthProviderResponse extends BaseResponse {
7070
@Param(description = "path of the domain the provider belongs to (empty for global)", since = "4.23.0")
7171
private String domainPath;
7272

73+
@SerializedName(ApiConstants.TYPE)
74+
@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")
75+
private String type;
76+
77+
@SerializedName(ApiConstants.ISSUER_URL)
78+
@Param(description = "Issuer URL of the OpenID Connect provider, used for discovery", since = "4.24.0")
79+
private String issuerUrl;
80+
7381
@SerializedName(ApiConstants.AUTHORIZE_URL)
7482
@Param(description = "Authorize URL registered in the OAuth provider")
7583
private String authorizeUrl;
@@ -180,6 +188,22 @@ public void setDomainPath(String domainPath) {
180188
this.domainPath = domainPath;
181189
}
182190

191+
public String getType() {
192+
return type;
193+
}
194+
195+
public void setType(String type) {
196+
this.type = type;
197+
}
198+
199+
public String getIssuerUrl() {
200+
return issuerUrl;
201+
}
202+
203+
public void setIssuerUrl(String issuerUrl) {
204+
this.issuerUrl = issuerUrl;
205+
}
206+
183207
public String getAuthorizeUrl() {
184208
return authorizeUrl;
185209
}

0 commit comments

Comments
 (0)