register required action

This commit is contained in:
Bill Burke 2015-08-14 12:03:37 -04:00
parent d11a83d6e2
commit 1f13f6372a
15 changed files with 297 additions and 43 deletions

View file

@ -637,8 +637,8 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
to the out of the box registration page.
An additional SPI was created to be able to do this. It basically allows
you to add validation of form elements on the page as well as to initialize UserModel attributes and data
after the user has been registered. We'll look at the implementation of the recaptcha support that
Keycloak provides out of the box to show you how to do this.
after the user has been registered. We'll look at both the implementation of the user profile registration
processing as well as the registration Google Recaptcha plugin.
</para>
<section>
<title>Implementation FormAction Interface</title>
@ -646,7 +646,7 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
The core interface you have to implement is the FormAction interface. A FormAction is responsible for
rendering and processing a portion of the page. Rendering is done in the buildPage() method, validation
is done in the validate() method, post validation operations are done in success(). Let's first take a look
at buildPage()
at buildPage() method of the Recaptcha plugin.
<programlisting><![CDATA[
@Override
public void buildPage(FormContext context, LoginFormsProvider form) {
@ -667,7 +667,7 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
</programlisting>
</para>
<para>
The buildPage() method is a callback by the form flow to help render the page. It receives a form parameter
The Recaptcha buildPage() method is a callback by the form flow to help render the page. It receives a form parameter
which is a LoginFormsProvider. You can add additional attributes to the form provider so that they can
be displayed in the HTML page generated by the registration Freemarker template.
</para>
@ -681,9 +681,13 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
Recaptcha also has the requirement of loading a javascript script. You can do this by calling LoginFormsProvider.addScript()
passing in the URL.
</para>
<para>
For user profile processing, there is no additional information that it needs to add to the form, so its buildPage() method
is empty.
</para>
<para>
The next meaty part of this interface is the validate() method. This is called immediately upon receiving a form
post.
post. Let's look at the Recaptcha's plugin first.
<programlisting><![CDATA[
@Override
public void validate(ValidationContext context) {
@ -721,11 +725,78 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
format of a form element, i.e. an alternative email attribute.
</para>
<para>
After all validations have been processed then, the form flow then invokes the FormAction.success() method. For recaptcha
this is a no-op, but if you have additional metadata you want to add to UserModel, you can do that in success() method.
Let's also look at the user profile plugin that is used to validate email address and other user information
when registering.
<programlisting><![CDATA[
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> errors = new ArrayList<>();
String eventError = Errors.INVALID_REGISTRATION;
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_FIRST_NAME)))) {
errors.add(new FormMessage(RegistrationPage.FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME));
}
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_LAST_NAME)))) {
errors.add(new FormMessage(RegistrationPage.FIELD_LAST_NAME, Messages.MISSING_LAST_NAME));
}
String email = formData.getFirst(Validation.FIELD_EMAIL);
if (Validation.isBlank(email)) {
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.MISSING_EMAIL));
} else if (!Validation.isEmailValid(email)) {
formData.remove(Validation.FIELD_EMAIL);
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.INVALID_EMAIL));
}
if (context.getSession().users().getUserByEmail(email, context.getRealm()) != null) {
formData.remove(Validation.FIELD_EMAIL);
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.EMAIL_EXISTS));
}
if (errors.size() > 0) {
context.validationError(formData, errors);
return;
} else {
context.success();
}
}]]>
</programlisting>
</para>
<para>
Finally the FormActionFactory class is really implemented similarly to AuthenticatorFactory, so we won't go over it.
As you can see, this validate() method of user profile processing makes sure that the email, first, and last name
are filled in in the form. It also makes sure that email is in the right format. If any of these validations
fail, an error message is queued up for rendering. Any fields in error are removed from the form data. Error messages
are represented by the FormMessage class. The first parameter of the constructor of this class takes the HTML
element id. The input in error will be highlighted when the form is re-rendered. The second parameter is
a message reference id. This id must correspond to a property in one of the localized message bundle files.
in the theme.
</para>
<para>
After all validations have been processed then, the form flow then invokes the FormAction.success() method. For recaptcha
this is a no-op, so we won't go over it. For user profile processing, this method fills in values in the registered
user.
<programlisting><![CDATA[
@Override
public void success(FormContext context) {
UserModel user = context.getUser();
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
user.setFirstName(formData.getFirst(RegistrationPage.FIELD_FIRST_NAME));
user.setLastName(formData.getFirst(RegistrationPage.FIELD_LAST_NAME));
user.setEmail(formData.getFirst(RegistrationPage.FIELD_EMAIL));
}]]>
</programlisting>
</para>
<para>
Pretty simple implementation. The UserModel of the newly registered user is obtained from the FormContext.
The appropriate methods are called to initialize UserModel data.
</para>
<para>
Finally, you are also required to define a FormActionFactory class. This class is implemented similarly to AuthenticatorFactory, so we won't go over it.
</para>
</section>
<section>
@ -735,7 +806,8 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
and must be contained in the <literal>META-INF/services/</literal> directory of your jar. This file must list the fully qualified classname
of each FormActionFactory implementation you have in the jar. For example:
<programlisting>
org.keycloak.examples.authenticator.registration.RecaptchaFormActionFactory
org.keycloak.authentication.forms.RegistrationProfile
org.keycloak.authentication.forms.RegistrationRecaptcha
</programlisting>
</para>
<para>
@ -758,7 +830,8 @@ public class SecretQuestionRequiredActionFactory implements RequiredActionFactor
Basically you'll have to copy the registration flow. Then click Actions menu to the right of
the Registration Form, and pick "Add Execution" to add a new execution. You'll pick the FormAction from the selection list.
Make sure your FormAction comes after "Registration User Creation" by using the down errors to move it if your FormAction
isn't already listed after "Registration User Creation".
isn't already listed after "Registration User Creation". You want your FormAction to come after user creation
because the success() method of Regsitration User Creation is responsible for creating the new UserModel.
</para>
<para>
After you've created your flow, you have to bind it to registration. If you go

View file

@ -1,27 +1,26 @@
Example User Federation Provider
===================================================
This is an example of user federation backed by a simple properties file. This properties file only contains username/password
key pairs. To deploy, build this directory then take the jar and copy it to standalone/configuration/providers. Alternatively you can deploy as a module by running:
This is an example of defining a custom Authenticator and Required action. This example is explained in the user documentation
of Keycloak. To deploy, build this directory then take the jar and copy it to standalone/configuration/providers. Alternatively you can deploy as a module by running:
KEYCLOAK_HOME/bin/jboss-cli.sh --command="module add --name=org.keycloak.examples.userprops --resources=target/federation-properties-example.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-model-api"
KEYCLOAK_HOME/bin/jboss-cli.sh --command="module add --name=org.keycloak.examples.secret-question --resources=target/authenticator-required-action-example.jar --dependencies=org.keycloak.keycloak-core,org.keycloak.keycloak-model-api,org.keycloak.keycloak-services"
Then registering the provider by editing keycloak-server.json and adding the module to the providers field:
"providers": [
....
"module:org.keycloak.examples.userprops"
"module:org.keycloak.examples.secret-question"
],
You will then have to restart the authentication server.
The ClasspathPropertiesFederationProvider is an example of a readonly provider. If you go to the Users/Federation
page of the admin console you will see this provider listed under "classpath-properties. To configure this provider you
specify a classpath to a properties file in the "path" field of the admin page for this plugin. This example includes
a "test-users.properties" within the JAR that you can use as the variable.
The FilePropertiesFederationProvider is an example of a writable provider. It synchronizes changes made to
username and password with the properties file. If you go to the Users/Federation page of the admin console you will
see this provider listed under "file-properties". To configure this provider you specify a fully qualified file path to
a properties file in the "path" field of the admin page for this plugin.
You then have to copy the secret-question.ftl file to the standalone/configuration/themes/base/login directory.
After you do all this, you then have to reboot keycloak. When reboot is complete, you will need to log into
the admin console to create a new flow with your new authenticator.
If you go to the Authentication menu item and go to the Flow tab, you will be able to view the currently
defined flows. You cannot modify an built in flows, so, to add the Authenticator you
have to copy an existing flow or create your own. I'm hoping the UI is intuitive enough so that you
can figure out for yourself how to create a flow and add the Authenticator. We're looking to add a screencast
to show this in action.

View file

@ -10,7 +10,7 @@
<description/>
<modelVersion>4.0.0</modelVersion>
<artifactId>authenticator-example</artifactId>
<artifactId>authenticator-required-action-example</artifactId>
<packaging>jar</packaging>
<dependencies>
@ -42,6 +42,6 @@
</dependencies>
<build>
<finalName>federation-properties-example</finalName>
<finalName>authenticator-required-action-example</finalName>
</build>
</project>

View file

@ -1117,12 +1117,15 @@ module.config([ '$routeProvider', function($routeProvider) {
},
controller : 'AuthenticationFlowsCtrl'
})
.when('/realms/:realm/authentication/flows/:flow/create/execution', {
.when('/realms/:realm/authentication/flows/:flow/create/execution/:topFlow', {
templateUrl : resourceUrl + '/partials/create-execution.html',
resolve : {
realm : function(RealmLoader) {
return RealmLoader();
},
topFlow: function($route) {
return $route.current.params.topFlow;
},
parentFlow : function(AuthenticationFlowLoader) {
return AuthenticationFlowLoader();
},
@ -1135,12 +1138,15 @@ module.config([ '$routeProvider', function($routeProvider) {
},
controller : 'CreateExecutionCtrl'
})
.when('/realms/:realm/authentication/flows/:flow/create/flow/execution', {
.when('/realms/:realm/authentication/flows/:flow/create/flow/execution/:topFlow', {
templateUrl : resourceUrl + '/partials/create-flow-execution.html',
resolve : {
realm : function(RealmLoader) {
return RealmLoader();
},
topFlow: function($route) {
return $route.current.params.topFlow;
},
parentFlow : function(AuthenticationFlowLoader) {
return AuthenticationFlowLoader();
},
@ -1164,6 +1170,9 @@ module.config([ '$routeProvider', function($routeProvider) {
resolve : {
realm : function(RealmLoader) {
return RealmLoader();
},
unregisteredRequiredActions : function(UnregisteredRequiredActionsListLoader) {
return UnregisteredRequiredActionsListLoader();
}
},
controller : 'RequiredActionsCtrl'

View file

@ -1651,7 +1651,7 @@ module.controller('CreateFlowCtrl', function($scope, realm,
};
});
module.controller('CreateExecutionFlowCtrl', function($scope, realm, parentFlow, formProviders,
module.controller('CreateExecutionFlowCtrl', function($scope, realm, topFlow, parentFlow, formProviders,
CreateExecutionFlow,
Notifications, $location) {
$scope.realm = realm;
@ -1669,16 +1669,16 @@ module.controller('CreateExecutionFlowCtrl', function($scope, realm, parentFlow,
$scope.save = function() {
$scope.flow.provider = $scope.provider.id;
CreateExecutionFlow.save({realm: realm.realm, alias: parentFlow.alias}, $scope.flow, function() {
$location.url("/realms/" + realm.realm + "/authentication/flows/" + parentFlow.alias);
$location.url("/realms/" + realm.realm + "/authentication/flows/" + topFlow);
Notifications.success("Flow Created.");
})
}
$scope.cancel = function() {
$location.url("/realms/" + realm.realm + "/authentication/flows/" + parentFlow.alias);
$location.url("/realms/" + realm.realm + "/authentication/flows/" + topFlow);
};
});
module.controller('CreateExecutionCtrl', function($scope, realm, parentFlow, formActionProviders, authenticatorProviders,
module.controller('CreateExecutionCtrl', function($scope, realm, topFlow, parentFlow, formActionProviders, authenticatorProviders,
CreateExecution,
Notifications, $location) {
$scope.realm = realm;
@ -1700,12 +1700,12 @@ module.controller('CreateExecutionCtrl', function($scope, realm, parentFlow, for
provider: $scope.provider.id
}
CreateExecution.save({realm: realm.realm, alias: parentFlow.alias}, execution, function() {
$location.url("/realms/" + realm.realm + "/authentication/flows/" + parentFlow.alias);
$location.url("/realms/" + realm.realm + "/authentication/flows/" + topFlow);
Notifications.success("Execution Created.");
})
}
$scope.cancel = function() {
$location.url("/realms/" + realm.realm + "/authentication/flows/" + parentFlow.alias);
$location.url("/realms/" + realm.realm + "/authentication/flows/" + topFlow);
};
});
@ -1793,12 +1793,12 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo
}
$scope.addSubFlow = function(execution) {
$location.url("/realms/" + realm.realm + '/authentication/flows/' + execution.flowId + '/create/flow/execution');
$location.url("/realms/" + realm.realm + '/authentication/flows/' + execution.flowId + '/create/flow/execution/' + $scope.flow.alias);
}
$scope.addSubFlowExecution = function(execution) {
$location.url("/realms/" + realm.realm + '/authentication/flows/' + execution.flowId + '/create/execution');
$location.url("/realms/" + realm.realm + '/authentication/flows/' + execution.flowId + '/create/execution/' + $scope.flow.alias);
}
@ -1853,13 +1853,16 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo
});
module.controller('RequiredActionsCtrl', function($scope, realm, RequiredActions, Notifications) {
module.controller('RequiredActionsCtrl', function($scope, realm, unregisteredRequiredActions,
$modal, $route,
RegisterRequiredAction, RequiredActions, Notifications) {
console.log('RequiredActionsCtrl');
$scope.realm = realm;
$scope.unregisteredRequiredActions = unregisteredRequiredActions;
$scope.requiredActions = [];
var setupRequiredActionsForm = function() {
console.log('setupRequiredActionsForm');
RequiredActions.query({id: realm.realm}, function(data) {
RequiredActions.query({realm: realm.realm}, function(data) {
$scope.requiredActions = [];
for (var i = 0; i < data.length; i++) {
$scope.requiredActions.push(data[i]);
@ -1868,12 +1871,35 @@ module.controller('RequiredActionsCtrl', function($scope, realm, RequiredActions
};
$scope.updateRequiredAction = function(action) {
RequiredActions.update({id: realm.realm, alias: action.alias}, action, function() {
RequiredActions.update({realm: realm.realm, alias: action.alias}, action, function() {
Notifications.success("Required action updated");
setupRequiredActionsForm();
});
}
$scope.register = function() {
var controller = function($scope, $modalInstance) {
$scope.unregisteredRequiredActions = unregisteredRequiredActions;
$scope.selected = {
selected: $scope.unregisteredRequiredActions[0]
}
$scope.ok = function () {
$modalInstance.close();
RegisterRequiredAction.save({realm: realm.realm}, $scope.selected.selected);
$route.reload();
};
$scope.cancel = function () {
$modalInstance.dismiss('cancel');
};
}
$modal.open({
templateUrl: resourceUrl + '/partials/modal/unregistered-required-action-selector.html',
controller: controller,
resolve: {
}
});
}
setupRequiredActionsForm();

View file

@ -73,6 +73,14 @@ module.factory('RequiredActionsListLoader', function(Loader, RequiredActions, $r
});
});
module.factory('UnregisteredRequiredActionsListLoader', function(Loader, UnregisteredRequiredActions, $route, $q) {
return Loader.query(UnregisteredRequiredActions, function() {
return {
realm : $route.current.params.realm
}
});
});
module.factory('RealmSessionStatsLoader', function(Loader, RealmSessionStats, $route, $q) {
return Loader.get(RealmSessionStats, function() {
return {

View file

@ -229,7 +229,7 @@ module.factory('BruteForceUser', function($resource) {
module.factory('RequiredActions', function($resource) {
return $resource(authUrl + '/admin/realms/:id/authentication/required-actions/:alias', {
return $resource(authUrl + '/admin/realms/:realm/authentication/required-actions/:alias', {
realm : '@realm',
alias : '@alias'
}, {
@ -239,6 +239,18 @@ module.factory('RequiredActions', function($resource) {
});
});
module.factory('UnregisteredRequiredActions', function($resource) {
return $resource(authUrl + '/admin/realms/:realm/authentication/unregistered-required-actions', {
realm : '@realm'
});
});
module.factory('RegisterRequiredAction', function($resource) {
return $resource(authUrl + '/admin/realms/:realm/authentication/register-required-action', {
realm : '@realm'
});
});
module.factory('RealmLDAPConnectionTester', function($resource) {
return $resource(authUrl + '/admin/realms/:realm/testLDAPConnection');
});

View file

@ -0,0 +1,21 @@
<div class="modal-header">
<button type="button" class="close" ng-click="cancel()">
<span class="pficon pficon-close"></span>
</button>
<h4 class="modal-title">Register Required Action</h4>
</div>
<div class="modal-body">
<form>
<div>
<label class="control-label" for="selector">Required Action</label>
<select id="selector" class="form-control"
ng-model="selected.selected"
ng-options="r.name for r in unregisteredRequiredActions">
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-click="cancel()">Cancel</button>
<button type="button" class="btn btn-primary" ng-click="ok()">Ok</button>
</div>

View file

@ -4,6 +4,13 @@
<kc-tabs-authentication></kc-tabs-authentication>
<table class="table table-striped table-bordered">
<thead>
<tr data-ng-hide="unregisteredRequiredActions.length == 0">
<th colspan = "3" class="kc-table-actions">
<div class="pull-right" data-ng-show="access.manageRealm">
<button class="btn btn-default" data-ng-click="register()">Register</button>
</div>
</th>
</tr>
<tr data-ng-hide="requiredActions.length == 0">
<th>Required Action</th>
<th>Enabled</th>

View file

@ -54,6 +54,9 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
return authenticationFlow.processAction(actionExecution);
} else if (model.getId().equals(actionExecution)) {
AuthenticatorFactory factory = (AuthenticatorFactory) processor.getSession().getKeycloakSessionFactory().getProviderFactory(Authenticator.class, model.getAuthenticator());
if (factory == null) {
throw new RuntimeException("Unable to find factory for AuthenticatorFactory: " + model.getAuthenticator() + " did you forget to declare it in a META-INF/services file?");
}
Authenticator authenticator = factory.create(processor.getSession());
AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, authenticator, executions);
authenticator.action(result);
@ -106,7 +109,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
AuthenticatorFactory factory = (AuthenticatorFactory) processor.getSession().getKeycloakSessionFactory().getProviderFactory(Authenticator.class, model.getAuthenticator());
if (factory == null) {
throw new AuthenticationFlowException("Could not find AuthenticatorFactory for: " + model.getAuthenticator(), AuthenticationFlowError.INTERNAL_ERROR);
throw new RuntimeException("Unable to find factory for AuthenticatorFactory: " + model.getAuthenticator() + " did you forget to declare it in a META-INF/services file?");
}
Authenticator authenticator = factory.create(processor.getSession());
AuthenticationProcessor.logger.debugv("authenticator: {0}", factory.getId());

View file

@ -433,6 +433,9 @@ public class AuthenticationManager {
for (String action : requiredActions) {
RequiredActionProviderModel model = realm.getRequiredActionProviderByAlias(action);
RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId());
if (factory == null) {
throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?");
}
RequiredActionProvider actionProvider = factory.create(session);
RequiredActionContextResult context = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user, factory);
actionProvider.requiredActionChallenge(context);
@ -508,6 +511,9 @@ public class AuthenticationManager {
for (RequiredActionProviderModel model : realm.getRequiredActionProviders()) {
if (!model.isEnabled()) continue;
RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId());
if (factory == null) {
throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?");
}
RequiredActionProvider provider = factory.create(session);
RequiredActionContextResult result = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user, factory) {
@Override

View file

@ -12,6 +12,8 @@ import org.keycloak.authentication.DefaultAuthenticationFlow;
import org.keycloak.authentication.FormAction;
import org.keycloak.authentication.FormAuthenticationFlow;
import org.keycloak.authentication.FormAuthenticator;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
@ -682,6 +684,50 @@ public class AuthenticationManagementResource {
}
}
@Path("unregistered-required-actions")
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public List<Map<String, String>> getUnregisteredRequiredActions() {
List<ProviderFactory> factories = session.getKeycloakSessionFactory().getProviderFactories(RequiredActionProvider.class);
List<Map<String, String>> unregisteredList = new LinkedList<>();
for (ProviderFactory factory : factories) {
RequiredActionFactory requiredActionFactory = (RequiredActionFactory) factory;
boolean found = false;
for (RequiredActionProviderModel model : realm.getRequiredActionProviders()) {
if (model.getProviderId().equals(factory.getId())) {
found = true;
break;
}
}
if (!found) {
Map<String, String> data = new HashMap<>();
data.put("name", requiredActionFactory.getDisplayText());
data.put("providerId", requiredActionFactory.getId());
unregisteredList.add(data);
}
}
return unregisteredList;
}
@Path("register-required-action")
@POST
@Consumes(MediaType.APPLICATION_JSON)
@NoCache
public void registereRequiredAction(Map<String, String> data) {
String providerId = data.get("providerId");
String name = data.get("name");
RequiredActionProviderModel requiredAction = new RequiredActionProviderModel();
requiredAction.setAlias(providerId);
requiredAction.setName(name);
requiredAction.setProviderId(providerId);
requiredAction.setDefaultAction(false);
requiredAction.setEnabled(true);
realm.addRequiredActionProvider(requiredAction);
}
@Path("required-actions")
@GET

View file

@ -167,7 +167,7 @@ public class AccountTest {
});
}
//@Test
@Test
public void ideTesting() throws Exception {
Thread.sleep(100000000);
}

View file

@ -0,0 +1,43 @@
package org.keycloak.testsuite.actions;
import org.keycloak.Config;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class DummyRequiredActionFactory implements RequiredActionFactory {
@Override
public String getDisplayText() {
return "Dummy Action";
}
@Override
public RequiredActionProvider create(KeycloakSession session) {
return null;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "dummy-action";
}
}

View file

@ -0,0 +1 @@
org.keycloak.testsuite.actions.DummyRequiredActionFactory