diff --git a/openidm-authnfilter/src/main/java/org/forgerock/openidm/auth/AuthenticationService.java b/openidm-authnfilter/src/main/java/org/forgerock/openidm/auth/AuthenticationService.java index 567e6fd8e..47a6106a8 100644 --- a/openidm-authnfilter/src/main/java/org/forgerock/openidm/auth/AuthenticationService.java +++ b/openidm-authnfilter/src/main/java/org/forgerock/openidm/auth/AuthenticationService.java @@ -12,7 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS - * Portions copyright 2024-2025 3A Systems LLC. + * Portions copyright 2024-2026 3A Systems LLC. */ package org.forgerock.openidm.auth; @@ -243,17 +243,28 @@ public class AuthenticationService implements SingletonResourceProvider, Identit @Reference(policy = ReferencePolicy.DYNAMIC, target="(service.pid=org.forgerock.openidm.auth.config)") private volatile AuthFilterWrapper authFilterWrapper; - @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL) private volatile IdentityProviderService identityProviderService; - void bindIdentityProviderService(IdentityProviderService identityProviderService) { + @Reference( + name = "identityProviderService", + policy = ReferencePolicy.DYNAMIC, + cardinality = ReferenceCardinality.OPTIONAL, + unbind = "unbindIdentityProviderService") + void bindIdentityProviderService(IdentityProviderService identityProviderService) + throws IdentityProviderServiceException { this.identityProviderService = identityProviderService; identityProviderService.registerIdentityProviderListener(this); + // no-op until activated; rebuilds the social auth modules if the service arrives later + identityProviderConfigChanged(); } - void unbindIdentityProviderService() { + void unbindIdentityProviderService(IdentityProviderService identityProviderService) + throws IdentityProviderServiceException { identityProviderService.unregisterIdentityProviderListener(this); - identityProviderService = null; + if (this.identityProviderService == identityProviderService) { + this.identityProviderService = null; + identityProviderConfigChanged(); + } } /** An on-demand Provider for the ConnectionFactory */ diff --git a/openidm-authnfilter/src/test/java/org/forgerock/openidm/auth/AuthenticationServiceTest.java b/openidm-authnfilter/src/test/java/org/forgerock/openidm/auth/AuthenticationServiceTest.java index 19f001e77..954b469c5 100644 --- a/openidm-authnfilter/src/test/java/org/forgerock/openidm/auth/AuthenticationServiceTest.java +++ b/openidm-authnfilter/src/test/java/org/forgerock/openidm/auth/AuthenticationServiceTest.java @@ -22,6 +22,7 @@ import static org.forgerock.json.resource.Requests.newReadRequest; import static org.forgerock.openidm.auth.AuthenticationService.Action; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import javax.security.auth.message.MessageInfo; @@ -86,7 +87,6 @@ public void setUp() throws Exception { OBJECT_MAPPER.readValue(getClass().getResource("/config/authentication.json"), Map.class)); // Instantiate the object to be used with proper mocked IdentityProviderService authenticationService = new AuthenticationService(); - authenticationService.setConfig(authenticationJson); } @AfterMethod @@ -111,6 +111,8 @@ public void testAmendAuthConfig() throws Exception { // Instantiate the object to be used with proper mocked IdentityProviderService authenticationService.bindIdentityProviderService(identityProviderService); + // the reference is bound before the component is activated with its configuration + authenticationService.setConfig(authenticationJson); // Call the amendAuthConfig to see the configuration of authentication.json be modified with // the injected identityProvider config from the IdentityProviderService @@ -154,6 +156,8 @@ public void testAmendAuthConfigWithTwoAuthTypes() throws Exception { // Instantiate the object to be used with proper mocked IdentityProviderService authenticationService.bindIdentityProviderService(identityProviderService); + // the reference is bound before the component is activated with its configuration + authenticationService.setConfig(authenticationJson); // Call the amendAuthConfig to see the configuration of authentication.json be modified with // the injected identityProvider config from the IdentityProviderService @@ -183,6 +187,8 @@ public void testNoProviderConfigsToInject() throws Exception { when(identityProviderService.getIdentityProviders()).thenReturn(providerConfigs); authenticationService.bindIdentityProviderService(identityProviderService); + // the reference is bound before the component is activated with its configuration + authenticationService.setConfig(authenticationJson); // Call the amendAuthConfig to see the configuration of authentication.json be modified with // the injected identityProvider config from the IdentityProviderService; in this test case @@ -234,6 +240,32 @@ public void amendAuthConfigShouldRemoveSocialProvidersModuleWhenIdentityProvider assertThat(authenticationJson.get(AUTH_MODULES).size()).isEqualTo(1); } + @Test + public void bindIdentityProviderServiceShouldRegisterListener() throws Exception { + final IdentityProviderService identityProviderService = mock(IdentityProviderService.class); + + authenticationService.bindIdentityProviderService(identityProviderService); + + verify(identityProviderService).registerIdentityProviderListener(authenticationService); + } + + @Test + public void unbindIdentityProviderServiceShouldUnregisterListenerAndStopInjectingProviders() throws Exception { + final IdentityProviderService identityProviderService = mock(IdentityProviderService.class); + final List openIdProviderConfigs = new ArrayList<>(); + openIdProviderConfigs.add(ProviderConfigMapper.toProviderConfig(googleIdentityProvider)); + when(identityProviderService.getIdentityProviders()).thenReturn(openIdProviderConfigs); + + authenticationService.bindIdentityProviderService(identityProviderService); + authenticationService.unbindIdentityProviderService(identityProviderService); + authenticationService.setConfig(authenticationJson); + authenticationService.amendAuthConfig(authenticationJson.get(AUTH_MODULES)); + + verify(identityProviderService).unregisterIdentityProviderListener(authenticationService); + // only the stand-alone OPENID_CONNECT module is left, no module was generated from the provider + assertThat(authenticationJson.get(AUTH_MODULES).size()).isEqualTo(1); + } + /** * Tests that the attribute that {@link JwtSessionModule#isLogoutRequest(MessageInfo)} expects is present in the * attributesContext. diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-auth-modules.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-auth-modules.adoc index cccac0dd8..b86d9e6fc 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/appendix-auth-modules.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/appendix-auth-modules.adoc @@ -294,11 +294,11 @@ In general, if you add a custom property, the Admin UI writes changes to the `au [#social-providers-module-details] === SOCIAL_PROVIDERS Module Configuration Options -The `SOCIAL_PROVIDERS` module is a meta-module (template) that dynamically generates `OPENID_CONNECT` and `OAUTH` authentication modules at startup for supported providers registered in the `IdentityProviderService`. The identity provider configurations themselves (client IDs, client secrets, authorization endpoints, etc.) are defined in `conf/identityProviders.json`, not inside the `SOCIAL_PROVIDERS` module entry. +The `SOCIAL_PROVIDERS` module is a meta-module (template) that dynamically generates `OPENID_CONNECT` and `OAUTH` authentication modules at startup for supported providers registered in the `IdentityProviderService`. The identity provider configurations themselves (client IDs, client secrets, authorization endpoints, etc.) are defined in one `conf/identityProvider-.json` file per provider, not inside the `SOCIAL_PROVIDERS` module entry. `conf/identityProviders.json` only holds the catalog of providers that the Admin UI offers; see xref:chap-auth.adoc#social-providers-module["SOCIAL_PROVIDERS"]. [NOTE] ====== -The `SOCIAL_PROVIDERS` entry is removed from the active authentication module list at startup — it is never initialized as an authenticator itself. Each `OPENID_CONNECT` or `OAUTH` provider entry in `conf/identityProviders.json` results in exactly one generated authentication module. +The `SOCIAL_PROVIDERS` entry is removed from the active authentication module list at startup — it is never initialized as an authenticator itself. Each `conf/identityProvider-.json` file results in exactly one generated `OPENID_CONNECT` or `OAUTH` authentication module, which carries the `enabled` flag of that provider. ====== [#social-providers-module-prop-basic] diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/chap-auth.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/chap-auth.adoc index c67d7ec70..352801dfd 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/chap-auth.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/chap-auth.adoc @@ -481,17 +481,54 @@ IWA:: The IWA module enables users to authenticate by using Integrated Windows Authentication (IWA), rather than by providing a username and password. For information about configuring the IWA module with OpenIDM, see xref:#openidm-auth-kerberos["Configuring IWA Authentication"]. [[social-providers-module]]SOCIAL_PROVIDERS:: -The `SOCIAL_PROVIDERS` module is a __meta-module__ (template) that bridges the social identity provider configuration in `conf/identityProviders.json` and the OpenIDM authentication filter. It is not an authenticator itself — instead, at startup it scans all providers registered with the `IdentityProviderService`, removes itself from the active module list, and dynamically generates the corresponding `OPENID_CONNECT` or `OAUTH` authentication modules: +The `SOCIAL_PROVIDERS` module is a __meta-module__ (template) that bridges the social identity providers configured in `conf/identityProvider-.json` files and the OpenIDM authentication filter. It is not an authenticator itself — instead, at startup it scans all providers registered with the `IdentityProviderService`, removes itself from the active module list, and dynamically generates the corresponding `OPENID_CONNECT` or `OAUTH` authentication modules: + * For each provider of type `OPENID_CONNECT`, an `OPENID_CONNECT` auth module is generated with `openIdConnectHeader: "authToken"`. * For each provider of type `OAUTH`, an `OAUTH` auth module is generated with `authTokenHeader: "authToken"` and `authResolverHeader: "provider"`. + -The generated modules inherit the `augmentSecurityContext`, `propertyMapping`, and `defaultUserRoles` values from the `SOCIAL_PROVIDERS` template entry. +The generated modules inherit the `augmentSecurityContext`, `propertyMapping`, and `defaultUserRoles` values from the `SOCIAL_PROVIDERS` template entry. Each generated module also carries the `enabled` flag of its provider, so a provider with `"enabled" : false` produces no active module. + -Providers are configured separately in `conf/identityProviders.json`, or via the Admin UI under *Configure > Social ID Providers*. The `SOCIAL_PROVIDERS` module acts as a single configuration point so that operators do not need to add individual `OPENID_CONNECT` or `OAUTH` entries to `authentication.json` for every social provider. +Each provider is configured in a file of its own, `conf/identityProvider-.json`, for example `conf/identityProvider-google.json`. The provider name is taken from the file name. You can write the file by hand, or enable the provider in the Admin UI under *Configure > Social ID Providers*, which writes the same file. OpenIDM stores the `client_secret` of these files encrypted. The `SOCIAL_PROVIDERS` module acts as a single configuration point so that operators do not need to add individual `OPENID_CONNECT` or `OAUTH` entries to `authentication.json` for every social provider. + ++ +A sample `conf/identityProvider-google.json` is as follows: ++ +[source, json] +---- +{ + "name" : "google", + "type" : "OPENID_CONNECT", + "enabled" : true, + "authorization_endpoint" : "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint" : "https://oauth2.googleapis.com/token", + "userinfo_endpoint" : "https://openidconnect.googleapis.com/v1/userinfo", + "well-known" : "https://accounts.google.com/.well-known/openid-configuration", + "client_id" : "your-client-id", + "client_secret" : "your-client-secret", + "scope" : [ + "openid", + "profile", + "email" + ], + "authenticationId" : "sub", + "propertyMap" : [ + { + "source" : "sub", + "target" : "id" + }, + { + "source" : "email", + "target" : "username" + } + ] +} +---- + ++ +`conf/identityProviders.json` is the configuration of the identity provider service itself. Its `providers` list is the catalog of supported providers (Google, Facebook, and LinkedIn by default): the templates that the Admin UI offers under *Configure > Social ID Providers*, and that `POST /openidm/identityProviders?_action=availableProviders` returns. An entry in the catalog is not an active provider, and client credentials placed there are neither used nor encrypted. Keep the file in place: without it the identity provider service does not start, and no social provider can be configured. + [NOTE] diff --git a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/impl/IdentityProviderService.java b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/impl/IdentityProviderService.java index 469a88da0..6be063eb4 100644 --- a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/impl/IdentityProviderService.java +++ b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/impl/IdentityProviderService.java @@ -12,14 +12,16 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. - * Portions Copyrighted 2024 3A Systems LLC. + * Portions Copyrighted 2024-2026 3A Systems LLC. */ package org.forgerock.openidm.idp.impl; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import static org.forgerock.http.handler.HttpClientHandler.OPTION_LOADER; import static org.forgerock.json.JsonValue.field; @@ -143,24 +145,18 @@ private enum Action { availableProviders, getProfile } * The String param in Map is referring to the * type of auth the identity provider supports. */ + private final Map> identityProviders = new ConcurrentHashMap<>(); + @Reference( + name = "identityProviders", service = IdentityProviderConfig.class, cardinality = ReferenceCardinality.MULTIPLE, - policy = ReferencePolicy.DYNAMIC) - private final Map> identityProviders = new ConcurrentHashMap<>(); - + policy = ReferencePolicy.DYNAMIC, + unbind = "unbindIdentityProviderConfig") protected void bindIdentityProviderConfig(final IdentityProviderConfig config) throws IdentityProviderServiceException { - // for this to be true, we do not have any identityProviders of this type - if (!identityProviders.containsKey(config.getIdentityProviderConfig().getType())) { - // initialize new array list to store providers of this type - List providers = new ArrayList<>(); - providers.add(config); - identityProviders.put(config.getIdentityProviderConfig().getType(), providers); - } else { - // we currently have existing configs of this type, just add to it - identityProviders.get(config.getIdentityProviderConfig().getType()).add(config); - } + identityProviders.computeIfAbsent(config.getIdentityProviderConfig().getType(), + type -> new CopyOnWriteArrayList<>()).add(config); notifyListeners(); } @@ -201,11 +197,12 @@ public void deactivate(ComponentContext context) { */ public List getIdentityProviderByType(final String type) { final List providers = new ArrayList<>(); - if (identityProviders == null || identityProviders.size() == 0) { + if (identityProviders.isEmpty()) { logger.debug("No Identity Providers have been configured."); return providers; } - for (final IdentityProviderConfig config : identityProviders.get(type)) { + for (final IdentityProviderConfig config + : identityProviders.getOrDefault(type, Collections.emptyList())) { providers.add(config.getIdentityProviderConfig()); } return providers; diff --git a/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/impl/IdentityProviderServiceTest.java b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/impl/IdentityProviderServiceTest.java index 28b988b9a..6f6519fd3 100644 --- a/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/impl/IdentityProviderServiceTest.java +++ b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/impl/IdentityProviderServiceTest.java @@ -21,6 +21,8 @@ import static org.forgerock.json.resource.Requests.newReadRequest; import static org.forgerock.json.test.assertj.AssertJJsonValueAssert.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; @@ -96,4 +98,36 @@ public void testReadInstance() throws Exception { assertThat(google).doesNotContain("client_secret"); // it should be removed by readInstance assertThat(google.isEqualTo(expected)).isTrue(); } + + @Test + public void testGetIdentityProviderByType() throws Exception { + IdentityProviderConfig idpConfig = mock(IdentityProviderConfig.class); + when(idpConfig.getIdentityProviderConfig()).thenReturn(googleIdentityProvider); + + IdentityProviderService service = new IdentityProviderService(); + service.bindIdentityProviderConfig(idpConfig); + + assertThat(service.getIdentityProviderByType("OPENID_CONNECT")).containsExactly(googleIdentityProvider); + // a type with no bound provider yields an empty list rather than failing + assertThat(service.getIdentityProviderByType("OAUTH")).isEmpty(); + } + + @Test + public void testUnbindIdentityProviderConfig() throws Exception { + IdentityProviderConfig idpConfig = mock(IdentityProviderConfig.class); + when(idpConfig.getIdentityProviderConfig()).thenReturn(googleIdentityProvider); + IdentityProviderListener listener = mock(IdentityProviderListener.class); + when(listener.getListenerName()).thenReturn("listener"); + + IdentityProviderService service = new IdentityProviderService(); + service.registerIdentityProviderListener(listener); + service.bindIdentityProviderConfig(idpConfig); + assertThat(service.getIdentityProvider("google")).isSameAs(googleIdentityProvider); + + service.unbindIdentityProviderConfig(idpConfig); + + assertThat(service.getIdentityProviders()).isEmpty(); + assertThat(service.getIdentityProvider("google")).isNull(); + verify(listener, times(2)).identityProviderConfigChanged(); + } } \ No newline at end of file diff --git a/openidm-zip/src/main/resources/conf/identityProviders.json b/openidm-zip/src/main/resources/conf/identityProviders.json new file mode 100644 index 000000000..d961f130c --- /dev/null +++ b/openidm-zip/src/main/resources/conf/identityProviders.json @@ -0,0 +1,142 @@ +{ + "providers" : [ + { + "name" : "google", + "type" : "OPENID_CONNECT", + "icon" : "", + "authorization_endpoint" : "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint" : "https://oauth2.googleapis.com/token", + "userinfo_endpoint" : "https://openidconnect.googleapis.com/v1/userinfo", + "well-known" : "https://accounts.google.com/.well-known/openid-configuration", + "client_id" : "", + "client_secret" : "", + "scope" : [ + "openid", + "profile", + "email" + ], + "authenticationId" : "sub", + "propertyMap" : [ + { + "source" : "sub", + "target" : "id" + }, + { + "source" : "name", + "target" : "displayName" + }, + { + "source" : "given_name", + "target" : "givenName" + }, + { + "source" : "family_name", + "target" : "familyName" + }, + { + "source" : "picture", + "target" : "photoUrl" + }, + { + "source" : "email", + "target" : "email" + }, + { + "source" : "email", + "target" : "username" + } + ] + }, + { + "name" : "facebook", + "type" : "OAUTH", + "icon" : "", + "authorization_endpoint" : "https://www.facebook.com/dialog/oauth", + "token_endpoint" : "https://graph.facebook.com/oauth/access_token", + "userinfo_endpoint" : "https://graph.facebook.com/me?fields=id,name,email,first_name,last_name", + "client_id" : "", + "client_secret" : "", + "scope" : [ + "public_profile", + "email" + ], + "authenticationId" : "id", + "propertyMap" : [ + { + "source" : "id", + "target" : "id" + }, + { + "source" : "name", + "target" : "displayName" + }, + { + "source" : "first_name", + "target" : "givenName" + }, + { + "source" : "last_name", + "target" : "familyName" + }, + { + "source" : "email", + "target" : "email" + }, + { + "source" : "email", + "target" : "username" + } + ] + }, + { + "name" : "linkedIn", + "type" : "OAUTH", + "icon" : "", + "authorization_endpoint" : "https://www.linkedin.com/oauth/v2/authorization", + "token_endpoint" : "https://www.linkedin.com/oauth/v2/accessToken", + "userinfo_endpoint" : "https://api.linkedin.com/v2/userinfo", + "client_id" : "", + "client_secret" : "", + "scope" : [ + "openid", + "profile", + "email" + ], + "authenticationId" : "sub", + "propertyMap" : [ + { + "source" : "sub", + "target" : "id" + }, + { + "source" : "name", + "target" : "displayName" + }, + { + "source" : "given_name", + "target" : "givenName" + }, + { + "source" : "family_name", + "target" : "familyName" + }, + { + "source" : "picture", + "target" : "photoUrl" + }, + { + "source" : "email", + "target" : "email" + }, + { + "source" : "email", + "target" : "username" + }, + { + "source" : "locale", + "target" : "locale" + } + ] + } + ] +}