diff --git a/server-spi/src/main/java/org/keycloak/models/ScriptModel.java b/server-spi/src/main/java/org/keycloak/models/ScriptModel.java
index 8d6d5fd665..c4b9735030 100644
--- a/server-spi/src/main/java/org/keycloak/models/ScriptModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/ScriptModel.java
@@ -1,12 +1,33 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.models;
/**
- * Denotes an executable Script with metadata.
+ * A representation of a Script with some additional meta-data.
*
* @author Thomas Darimont
*/
public interface ScriptModel {
+ /**
+ * MIME-Type for JavaScript
+ */
+ String TEXT_JAVASCRIPT = "text/javascript";
+
/**
* Returns the unique id of the script. {@literal null} for ad-hoc created scripts.
*/
diff --git a/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java b/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java
deleted file mode 100644
index 342652fa17..0000000000
--- a/server-spi/src/main/java/org/keycloak/scripting/InvocableScript.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package org.keycloak.scripting;
-
-import org.keycloak.models.ScriptModel;
-
-import javax.script.Invocable;
-import javax.script.ScriptEngine;
-import javax.script.ScriptException;
-
-/**
- * @author Thomas Darimont
- */
-public class InvocableScript implements Invocable {
-
- /**
- * Holds the script metadata as well as the actual script.
- */
- private final ScriptModel script;
-
- /**
- * Holds the {@link ScriptEngine} instance initialized with the script code.
- */
- private final ScriptEngine scriptEngine;
-
- public InvocableScript(ScriptModel script, ScriptEngine scriptEngine) {
- this.script = script;
- this.scriptEngine = scriptEngine;
- }
-
- @Override
- public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptException, NoSuchMethodException {
- return getInvocableEngine().invokeMethod(thiz, name, args);
- }
-
- @Override
- public Object invokeFunction(String name, Object... args) throws ScriptException, NoSuchMethodException {
- return getInvocableEngine().invokeFunction(name, args);
- }
-
- @Override
- public T getInterface(Class clazz) {
- return getInvocableEngine().getInterface(clazz);
- }
-
- @Override
- public T getInterface(Object thiz, Class clazz) {
- return getInvocableEngine().getInterface(thiz, clazz);
- }
-
- private Invocable getInvocableEngine() {
- return (Invocable) scriptEngine;
- }
-
- /**
- * Returns {@literal true} iif the {@link ScriptEngine} has a function with the given {@code functionName}.
- * @param functionName
- * @return
- */
- public boolean hasFunction(String functionName){
-
- Object candidate = scriptEngine.getContext().getAttribute(functionName);
-
- return candidate != null;
- }
-}
diff --git a/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java b/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java
new file mode 100644
index 0000000000..c3859aba35
--- /dev/null
+++ b/server-spi/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.scripting;
+
+import org.keycloak.models.ScriptModel;
+
+import javax.script.Invocable;
+import javax.script.ScriptEngine;
+import javax.script.ScriptException;
+
+/**
+ * Wraps a {@link ScriptModel} and makes it {@link Invocable}.
+ *
+ * @author Thomas Darimont
+ */
+public class InvocableScriptAdapter implements Invocable {
+
+ /**
+ * Holds the {@ScriptModel}
+ */
+ private final ScriptModel scriptModel;
+
+ /**
+ * Holds the {@link ScriptEngine} instance initialized with the script code.
+ */
+ private final ScriptEngine scriptEngine;
+
+ /**
+ * Creates a new {@link InvocableScriptAdapter} instance.
+ *
+ * @param scriptModel must not be {@literal null}
+ * @param scriptEngine must not be {@literal null}
+ */
+ public InvocableScriptAdapter(ScriptModel scriptModel, ScriptEngine scriptEngine) {
+
+ if (scriptModel == null) {
+ throw new IllegalArgumentException("scriptModel must not be null");
+ }
+
+ if (scriptEngine == null) {
+ throw new IllegalArgumentException("scriptEngine must not be null");
+ }
+
+ this.scriptModel = scriptModel;
+ this.scriptEngine = loadScriptIntoEngine(scriptModel, scriptEngine);
+ }
+
+ @Override
+ public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptExecutionException {
+
+ try {
+ return getInvocableEngine().invokeMethod(thiz, name, args);
+ } catch (ScriptException | NoSuchMethodException e) {
+ throw new ScriptExecutionException(scriptModel, e);
+ }
+ }
+
+ @Override
+ public Object invokeFunction(String name, Object... args) throws ScriptExecutionException {
+ try {
+ return getInvocableEngine().invokeFunction(name, args);
+ } catch (ScriptException | NoSuchMethodException e) {
+ throw new ScriptExecutionException(scriptModel, e);
+ }
+ }
+
+ @Override
+ public T getInterface(Class clazz) {
+ return getInvocableEngine().getInterface(clazz);
+ }
+
+ @Override
+ public T getInterface(Object thiz, Class clazz) {
+ return getInvocableEngine().getInterface(thiz, clazz);
+ }
+
+ /**
+ * Returns {@literal true} if the {@link ScriptEngine} has a definition with the given {@code name}.
+ *
+ * @param name
+ * @return
+ */
+ public boolean isDefined(String name) {
+
+ Object candidate = scriptEngine.getContext().getAttribute(name);
+
+ return candidate != null;
+ }
+
+ private ScriptEngine loadScriptIntoEngine(ScriptModel script, ScriptEngine engine) {
+
+ try {
+ engine.eval(script.getCode());
+ } catch (ScriptException se) {
+ throw new ScriptExecutionException(script, se);
+ }
+
+ return engine;
+ }
+
+ private Invocable getInvocableEngine() {
+ return (Invocable) scriptEngine;
+ }
+}
diff --git a/server-spi/src/main/java/org/keycloak/scripting/Script.java b/server-spi/src/main/java/org/keycloak/scripting/Script.java
index 2e81372655..ef86902e24 100644
--- a/server-spi/src/main/java/org/keycloak/scripting/Script.java
+++ b/server-spi/src/main/java/org/keycloak/scripting/Script.java
@@ -1,8 +1,26 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import org.keycloak.models.ScriptModel;
/**
+ * A {@link ScriptModel} which holds some meta-data.
+ *
* @author Thomas Darimont
*/
public class Script implements ScriptModel {
diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java
index 9d55195977..9613eb64e4 100644
--- a/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java
+++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptBindingsConfigurer.java
@@ -1,17 +1,33 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import javax.script.Bindings;
/**
* Callback interface for customization of {@link Bindings} for a {@link javax.script.ScriptEngine}.
- *
+ * Used by {@link ScriptingProvider}
* @author Thomas Darimont
*/
@FunctionalInterface
public interface ScriptBindingsConfigurer {
/**
- * A default {@link ScriptBindingsConfigurer} leaves the Bindings empty.
+ * A default {@link ScriptBindingsConfigurer} that provides no Bindings.
*/
ScriptBindingsConfigurer EMPTY = new ScriptBindingsConfigurer() {
diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java
index e912ca915e..2063bd21b9 100644
--- a/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java
+++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptExecutionException.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import org.keycloak.models.ScriptModel;
@@ -11,7 +27,7 @@ import javax.script.ScriptException;
*/
public class ScriptExecutionException extends RuntimeException {
- public ScriptExecutionException(ScriptModel script, ScriptException se) {
- super("Error executing script '" + script.getName() + "'", se);
+ public ScriptExecutionException(ScriptModel script, Exception ex) {
+ super("Could not execute script '" + script.getName() + "' problem was: " + ex.getMessage(), ex);
}
}
diff --git a/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java b/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java
index 163120bc7c..67bad5a9c9 100644
--- a/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java
+++ b/server-spi/src/main/java/org/keycloak/scripting/ScriptingProvider.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import org.keycloak.models.ScriptModel;
@@ -13,20 +29,23 @@ import javax.script.ScriptEngine;
public interface ScriptingProvider extends Provider {
/**
- * Returns an {@link InvocableScript} based on the given {@link ScriptModel}.
- * The {@code InvocableScript} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}
+ * Returns an {@link InvocableScriptAdapter} based on the given {@link ScriptModel}.
+ * The {@code InvocableScriptAdapter} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}
*
- * @param script the script to wrap
+ * @param scriptModel the scriptModel to wrap
* @param bindingsConfigurer populates the {@link javax.script.Bindings}
* @return
*/
- InvocableScript prepareScript(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer);
+ InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer);
/**
- * Returns an {@link InvocableScript} based on the given {@link ScriptModel} with an {@link ScriptBindingsConfigurer#EMPTY} {@code ScriptBindingsConfigurer}.
- * @see #prepareScript(ScriptModel, ScriptBindingsConfigurer)
- * @param script
+ * Creates a new {@link ScriptModel} instance.
+ *
+ * @param realmId
+ * @param scriptName
+ * @param scriptCode
+ * @param scriptDescription
* @return
*/
- InvocableScript prepareScript(ScriptModel script);
+ ScriptModel createScript(String realmId, String mimeType, String scriptName, String scriptCode, String scriptDescription);
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java
index 895f0356f0..85a217ff1e 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticator.java
@@ -1,23 +1,82 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.authentication.authenticators.browser;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
+import org.keycloak.models.ScriptModel;
import org.keycloak.models.UserModel;
-import org.keycloak.scripting.InvocableScript;
-import org.keycloak.scripting.Script;
-import org.keycloak.scripting.ScriptBindingsConfigurer;
+import org.keycloak.scripting.InvocableScriptAdapter;
+import org.keycloak.scripting.ScriptExecutionException;
import org.keycloak.scripting.ScriptingProvider;
-import javax.script.Bindings;
-import javax.script.ScriptException;
import java.util.Map;
/**
* An {@link Authenticator} that can execute a configured script during authentication flow.
- * scripts must provide
+ *
+ * Scripts must at least provide one of the following functions:
+ *
+ * - {@code authenticate(..)} which is called from {@link Authenticator#authenticate(AuthenticationFlowContext)}
+ * - {@code action(..)} which is called from {@link Authenticator#action(AuthenticationFlowContext)}
+ *
+ *
+ *
+ * Custom {@link Authenticator Authenticator's} should at least provide the {@code authenticate(..)} function.
+ * The following script {@link javax.script.Bindings} are available for convenient use within script code.
+ *
+ * - {@code script} the {@link ScriptModel} to access script metadata
+ * - {@code realm} the {@link RealmModel}
+ * - {@code user} the current {@link UserModel}
+ * - {@code session} the active {@link KeycloakSession}
+ * - {@code httpRequest} the current {@link org.jboss.resteasy.spi.HttpRequest}
+ * - {@code LOG} a {@link org.jboss.logging.Logger} scoped to {@link ScriptBasedAuthenticator}/li>
+ *
+ *
+ *
+ * Additional context information can be extracted from the {@code context} argument passed to the {@code authenticate(context)}
+ * or {@code action(context)} function.
+ *
+ * An example {@link ScriptBasedAuthenticator} definition could look as follows:
+ *
+ * {@code
+ *
+ * AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
+ *
+ * function authenticate(context) {
+ *
+ * LOG.info(script.name + " --> trace auth for: " + user.username);
+ *
+ * if ( user.username === "tester"
+ * && user.getAttribute("someAttribute")
+ * && user.getAttribute("someAttribute").contains("someValue")) {
+ *
+ * context.failure(AuthenticationFlowError.INVALID_USER);
+ * return;
+ * }
+ *
+ * context.success();
+ * }
+ * }
+ *
*
* @author Thomas Darimont
*/
@@ -29,58 +88,51 @@ public class ScriptBasedAuthenticator implements Authenticator {
static final String SCRIPT_NAME = "scriptName";
static final String SCRIPT_DESCRIPTION = "scriptDescription";
- static final String ACTION = "action";
- static final String AUTHENTICATE = "authenticate";
- static final String TEXT_JAVASCRIPT = "text/javascript";
+ static final String ACTION_FUNCTION_NAME = "action";
+ static final String AUTHENTICATE_FUNCTION_NAME = "authenticate";
@Override
public void authenticate(AuthenticationFlowContext context) {
- tryInvoke(AUTHENTICATE, context);
+ tryInvoke(AUTHENTICATE_FUNCTION_NAME, context);
}
@Override
public void action(AuthenticationFlowContext context) {
- tryInvoke(ACTION, context);
+ tryInvoke(ACTION_FUNCTION_NAME, context);
}
private void tryInvoke(String functionName, AuthenticationFlowContext context) {
- InvocableScript script = getInvocableScript(context);
+ if (!hasAuthenticatorConfig(context)) {
+ // this is an empty not yet configured script authenticator
+ // we mark this execution as success to not lock out users due to incompletely configured authenticators.
+ context.success();
+ return;
+ }
- if (!script.hasFunction(functionName)) {
+ InvocableScriptAdapter invocableScriptAdapter = getInvocableScriptAdapter(context);
+
+ if (!invocableScriptAdapter.isDefined(functionName)) {
return;
}
try {
- //should context be wrapped in a readonly wrapper?
- script.invokeFunction(functionName, context);
- } catch (ScriptException | NoSuchMethodException e) {
+ //should context be wrapped in a read-only wrapper?
+ invocableScriptAdapter.invokeFunction(functionName, context);
+ } catch (ScriptExecutionException e) {
LOGGER.error(e);
+ context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
}
- private InvocableScript getInvocableScript(final AuthenticationFlowContext context) {
-
- final Script script = createAdhocScriptFromContext(context);
-
- ScriptBindingsConfigurer bindingsConfigurer = new ScriptBindingsConfigurer() {
-
- @Override
- public void configureBindings(Bindings bindings) {
-
- bindings.put("script", script);
- bindings.put("LOG", LOGGER);
- }
- };
-
- ScriptingProvider scripting = context.getSession().scripting();
-
- //how to deal with long running scripts -> timeout?
-
- return scripting.prepareScript(script, bindingsConfigurer);
+ private boolean hasAuthenticatorConfig(AuthenticationFlowContext context) {
+ return context != null
+ && context.getAuthenticatorConfig() != null
+ && context.getAuthenticatorConfig().getConfig() != null
+ && !context.getAuthenticatorConfig().getConfig().isEmpty();
}
- private Script createAdhocScriptFromContext(AuthenticationFlowContext context) {
+ private InvocableScriptAdapter getInvocableScriptAdapter(AuthenticationFlowContext context) {
Map config = context.getAuthenticatorConfig().getConfig();
@@ -90,21 +142,35 @@ public class ScriptBasedAuthenticator implements Authenticator {
RealmModel realm = context.getRealm();
- return new Script(null /* scriptId */, realm.getId(), scriptName, TEXT_JAVASCRIPT, scriptCode, scriptDescription);
+ ScriptingProvider scripting = context.getSession().scripting();
+
+ //TODO lookup script by scriptId instead of creating it every time
+ ScriptModel script = scripting.createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription);
+
+ //how to deal with long running scripts -> timeout?
+ return scripting.prepareInvocableScript(script, bindings -> {
+ bindings.put("script", script);
+ bindings.put("realm", context.getRealm());
+ bindings.put("user", context.getUser());
+ bindings.put("session", context.getSession());
+ bindings.put("httpRequest", context.getHttpRequest());
+ bindings.put("LOG", LOGGER);
+ });
}
@Override
public boolean requiresUser() {
- return false;
+ return true;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
- return false;
+ return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
+ //TODO make RequiredActions configurable in the script
//NOOP
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java
index b25af8f1fd..0528154522 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ScriptBasedAuthenticatorFactory.java
@@ -1,5 +1,23 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.authentication.authenticators.browser;
+import org.apache.commons.io.IOUtils;
+import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
@@ -8,6 +26,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
+import java.io.IOException;
import java.util.List;
import static java.util.Arrays.asList;
@@ -24,7 +43,9 @@ import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE;
*/
public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory {
- static final String PROVIDER_ID = "auth-script-based";
+ private static final Logger LOGGER = Logger.getLogger(ScriptBasedAuthenticatorFactory.class);
+
+ public static final String PROVIDER_ID = "auth-script-based";
static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
@@ -77,7 +98,7 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory {
@Override
public boolean isUserSetupAllowed() {
- return false;
+ return true;
}
@Override
@@ -87,12 +108,12 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory {
@Override
public String getDisplayType() {
- return "Script-based Authentication";
+ return "Script";
}
@Override
public String getHelpText() {
- return "Script based authentication.";
+ return "Script based authentication. Allows to define custom authentication logic via JavaScript.";
}
@Override
@@ -114,9 +135,16 @@ public class ScriptBasedAuthenticatorFactory implements AuthenticatorFactory {
script.setType(SCRIPT_TYPE);
script.setName(SCRIPT_CODE);
script.setLabel("Script Source");
- script.setDefaultValue("//enter your script here");
- script.setHelpText("The script used to authenticate. Scripts must at least define a function with the name 'authenticate' that accepts a context (AuthenticationFlowContext) parameter." +
- "This authenticator exposes the following additional variables: 'script', 'LOG'");
+
+ String scriptTemplate = "//enter your script code here";
+ try {
+ scriptTemplate = IOUtils.toString(getClass().getResource("/scripts/authenticator-template.js"));
+ } catch (IOException ioe) {
+ LOGGER.warn(ioe);
+ }
+ script.setDefaultValue(scriptTemplate);
+ script.setHelpText("The script used to authenticate. Scripts must at least define a function with the name 'authenticate(context)' that accepts a context (AuthenticationFlowContext) parameter.\n" +
+ "This authenticator exposes the following additional variables: 'script', 'realm', 'user', 'session', 'httpRequest', 'LOG'");
return asList(name, description, script);
}
diff --git a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java
index 140ec04fbd..5772f53055 100644
--- a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java
+++ b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import org.keycloak.models.ScriptModel;
@@ -6,7 +22,6 @@ import javax.script.Bindings;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
-import javax.script.ScriptException;
/**
* A {@link ScriptingProvider} that uses a {@link ScriptEngineManager} to evaluate scripts with a {@link ScriptEngine}.
@@ -18,40 +33,70 @@ public class DefaultScriptingProvider implements ScriptingProvider {
private final ScriptEngineManager scriptEngineManager;
public DefaultScriptingProvider(ScriptEngineManager scriptEngineManager) {
+
+ if (scriptEngineManager == null) {
+ throw new IllegalStateException("scriptEngineManager must not be null!");
+ }
+
this.scriptEngineManager = scriptEngineManager;
}
+ /**
+ * Wraps the provided {@link ScriptModel} in a {@link javax.script.Invocable} instance with bindings configured through the {@link ScriptBindingsConfigurer}.
+ *
+ * @param scriptModel must not be {@literal null}
+ * @param bindingsConfigurer must not be {@literal null}
+ * @return
+ */
@Override
- public InvocableScript prepareScript(ScriptModel script) {
- return prepareScript(script, ScriptBindingsConfigurer.EMPTY);
- }
+ public InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer) {
- @Override
- public InvocableScript prepareScript(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) {
-
- if (script == null) {
- throw new NullPointerException("script must not be null");
+ if (scriptModel == null) {
+ throw new IllegalArgumentException("script must not be null");
}
- if (script.getCode() == null || script.getCode().trim().isEmpty()) {
+ if (scriptModel.getCode() == null || scriptModel.getCode().trim().isEmpty()) {
throw new IllegalArgumentException("script must not be null or empty");
}
if (bindingsConfigurer == null) {
- throw new NullPointerException("bindingsConfigurer must not be null");
+ throw new IllegalArgumentException("bindingsConfigurer must not be null");
}
- ScriptEngine engine = lookupScriptEngineFor(script);
+ ScriptEngine engine = createPreparedScriptEngine(scriptModel, bindingsConfigurer);
- if (engine == null) {
+ return new InvocableScriptAdapter(scriptModel, engine);
+ }
+
+ //TODO allow scripts to be maintained independently of other components, e.g. with dedicated persistence
+ //TODO allow script lookup by (scriptId)
+ //TODO allow script lookup by (name, realmName)
+
+ @Override
+ public ScriptModel createScript(String realmId, String mimeType, String scriptName, String scriptCode, String scriptDescription) {
+
+ ScriptModel script = new Script(null /* scriptId */, realmId, scriptName, mimeType, scriptCode, scriptDescription);
+ return script;
+ }
+
+ /**
+ * Looks-up a {@link ScriptEngine} with prepared {@link Bindings} for the given {@link ScriptModel Script}.
+ *
+ * @param script
+ * @param bindingsConfigurer
+ * @return
+ */
+ private ScriptEngine createPreparedScriptEngine(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) {
+
+ ScriptEngine scriptEngine = lookupScriptEngineFor(script);
+
+ if (scriptEngine == null) {
throw new IllegalStateException("Could not find ScriptEngine for script: " + script);
}
- configureBindings(bindingsConfigurer, engine);
+ configureBindings(bindingsConfigurer, scriptEngine);
- loadScriptIntoEngine(script, engine);
-
- return new InvocableScript(script, engine);
+ return scriptEngine;
}
private void configureBindings(ScriptBindingsConfigurer bindingsConfigurer, ScriptEngine engine) {
@@ -61,15 +106,9 @@ public class DefaultScriptingProvider implements ScriptingProvider {
engine.setBindings(bindings, ScriptContext.ENGINE_SCOPE);
}
- private void loadScriptIntoEngine(ScriptModel script, ScriptEngine engine) {
-
- try {
- engine.eval(script.getCode());
- } catch (ScriptException se) {
- throw new ScriptExecutionException(script, se);
- }
- }
-
+ /**
+ * Looks-up a {@link ScriptEngine} based on the MIME-type provided by the given {@link Script}.
+ */
private ScriptEngine lookupScriptEngineFor(ScriptModel script) {
return scriptEngineManager.getEngineByMimeType(script.getMimeType());
}
diff --git a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java
index 2e8a431aa0..b00a058eee 100644
--- a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java
+++ b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProviderFactory.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
package org.keycloak.scripting;
import org.keycloak.Config;
@@ -13,11 +29,9 @@ public class DefaultScriptingProviderFactory implements ScriptingProviderFactory
static final String ID = "script-based-auth";
- private final ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
-
@Override
public ScriptingProvider create(KeycloakSession session) {
- return new DefaultScriptingProvider(scriptEngineManager);
+ return new DefaultScriptingProvider(ScriptEngineManagerHolder.SCRIPT_ENGINE_MANAGER);
}
@Override
@@ -39,4 +53,12 @@ public class DefaultScriptingProviderFactory implements ScriptingProviderFactory
public String getId() {
return ID;
}
+
+ /**
+ * Holder class for lazy initialization of {@link ScriptEngineManager}.
+ */
+ private static class ScriptEngineManagerHolder {
+
+ private static final ScriptEngineManager SCRIPT_ENGINE_MANAGER = new ScriptEngineManager();
+ }
}
diff --git a/services/src/main/resources/scripts/authenticator-template.js b/services/src/main/resources/scripts/authenticator-template.js
new file mode 100644
index 0000000000..73bb12475f
--- /dev/null
+++ b/services/src/main/resources/scripts/authenticator-template.js
@@ -0,0 +1,37 @@
+/*
+ * Template for JavaScript based authenticator's.
+ * See org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticatorFactory
+ */
+
+// import enum for error lookup
+AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
+
+/**
+ * An example authenticate function.
+ *
+ * The following variables are available for convenience:
+ * user - current user {@see org.keycloak.models.UserModel}
+ * realm - current realm {@see org.keycloak.models.RealmModel}
+ * session - current KeycloakSession {@see org.keycloak.models.KeycloakSession}
+ * httpRequest - current HttpRequest {@see org.jboss.resteasy.spi.HttpRequest}
+ * script - current script {@see org.keycloak.models.ScriptModel}
+ * LOG - current logger {@see org.jboss.logging.Logger}
+ *
+ * You one can extract current http request headers via:
+ * httpRequest.getHttpHeaders().getHeaderString("Forwarded")
+ *
+ * @param context {@see org.keycloak.authentication.AuthenticationFlowContext}
+ */
+function authenticate(context) {
+
+ LOG.info(script.name + " trace auth for: " + user.username);
+
+ var authShouldFail = false;
+ if (authShouldFail) {
+
+ context.failure(AuthenticationFlowError.INVALID_USER);
+ return;
+ }
+
+ context.success();
+}
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
index 4e07e6fbcc..0f2133b86c 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
@@ -136,7 +136,7 @@ public class ProvidersTest extends AbstractAuthenticationTest {
"Validates a OTP on a separate OTP form. Only shown if required based on the configured conditions.");
addProviderInfo(result, "auth-cookie", "Cookie", "Validates the SSO cookie set by the auth server.");
addProviderInfo(result, "auth-otp-form", "OTP Form", "Validates a OTP on a separate OTP form.");
- addProviderInfo(result, "auth-script-based", "Script-based Authentication", "Script based authentication.");
+ addProviderInfo(result, "auth-script-based", "Script", "Script based authentication. Allows to define custom authentication logic via JavaScript.");
addProviderInfo(result, "auth-spnego", "Kerberos", "Initiates the SPNEGO protocol. Most often used with Kerberos.");
addProviderInfo(result, "auth-username-password-form", "Username Password Form",
"Validates a username and password from login form.");
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java
new file mode 100644
index 0000000000..667c85ff59
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ScriptAuthenticatorTest.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.testsuite.forms;
+
+import org.apache.commons.io.IOUtils;
+import org.jboss.arquillian.graphene.page.Page;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.authentication.AuthenticationFlow;
+import org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticatorFactory;
+import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventType;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation;
+import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
+import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
+import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.storage.user.UserCredentialAuthenticationProvider;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.pages.LoginPage;
+import org.keycloak.testsuite.util.ExecutionBuilder;
+import org.keycloak.testsuite.util.FlowBuilder;
+import org.keycloak.testsuite.util.RealmBuilder;
+import org.keycloak.testsuite.util.UserBuilder;
+
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * Tests for {@link org.keycloak.authentication.authenticators.browser.ScriptBasedAuthenticator}
+ *
+ * @author Thomas Darimont
+ */
+public class ScriptAuthenticatorTest extends AbstractFlowTest {
+
+ UserRepresentation failUser;
+
+ UserRepresentation okayUser;
+
+ @Page
+ protected LoginPage loginPage;
+
+ @Rule
+ public AssertEvents events = new AssertEvents(this);
+
+ private AuthenticationFlowRepresentation flow;
+
+ @Override
+ public void configureTestRealm(RealmRepresentation testRealm) {
+
+ failUser = UserBuilder.create()
+ .id("fail")
+ .username("fail")
+ .email("fail@test.com")
+ .enabled(true)
+ .password("password")
+ .build();
+
+ okayUser = UserBuilder.create()
+ .id("user")
+ .username("user")
+ .email("user@test.com")
+ .enabled(true)
+ .password("password")
+ .build();
+
+ RealmBuilder.edit(testRealm)
+ .user(failUser)
+ .user(okayUser);
+ }
+
+ @Before
+ public void configureFlows() throws Exception {
+
+ String scriptFlow = "scriptBrowser";
+
+ AuthenticationFlowRepresentation scriptBrowserFlow = FlowBuilder.create()
+ .alias(scriptFlow)
+ .description("dummy pass through registration")
+ .providerId("basic-flow")
+ .topLevel(true)
+ .builtIn(false)
+ .build();
+
+ String scriptAuth = "scriptAuth";
+
+ Response createFlowResponse = testRealm().flows().createFlow(scriptBrowserFlow);
+ Assert.assertEquals(201, createFlowResponse.getStatus());
+
+ RealmRepresentation realm = testRealm().toRepresentation();
+ realm.setBrowserFlow(scriptFlow);
+ realm.setDirectGrantFlow(scriptFlow);
+ testRealm().update(realm);
+
+ this.flow = findFlowByAlias(scriptFlow);
+
+ AuthenticationExecutionRepresentation usernamePasswordFormExecution = ExecutionBuilder.create()
+ .id("username password form")
+ .parentFlow(this.flow.getId())
+ .requirement(AuthenticationExecutionModel.Requirement.REQUIRED.name())
+ .authenticator(UsernamePasswordFormFactory.PROVIDER_ID)
+ .build();
+
+ AuthenticationExecutionRepresentation authScriptExecution = ExecutionBuilder.create()
+ .id(scriptAuth)
+ .parentFlow(this.flow.getId())
+ .requirement(AuthenticationExecutionModel.Requirement.REQUIRED.name())
+ .authenticator(ScriptBasedAuthenticatorFactory.PROVIDER_ID)
+ .build();
+
+ Response addExecutionResponse = testRealm().flows().addExecution(usernamePasswordFormExecution);
+ Assert.assertEquals(201, addExecutionResponse.getStatus());
+
+ addExecutionResponse = testRealm().flows().addExecution(authScriptExecution);
+ Assert.assertEquals(201, addExecutionResponse.getStatus());
+
+ Response newExecutionConfigResponse = testRealm().flows().newExecutionConfig(scriptAuth, createScriptAuthConfig(scriptAuth, "authenticator-example.js", "/scripts/authenticator-example.js", "simple script based authenticator"));
+ Assert.assertEquals(201, newExecutionConfigResponse.getStatus());
+ }
+
+ /**
+ * KEYCLOAK-3491
+ */
+ @Test
+ public void loginShouldWorkWithScriptAuthenticator() {
+
+ loginPage.open();
+
+ loginPage.login(okayUser.getUsername(), "password");
+
+ events.expectLogin().user(okayUser.getId()).detail(Details.USERNAME, okayUser.getUsername()).assertEvent();
+ }
+
+ /**
+ * KEYCLOAK-3491
+ */
+ @Test
+ public void loginShouldFailWithScriptAuthenticator() {
+
+ loginPage.open();
+
+ loginPage.login(failUser.getUsername(), "password");
+
+ events.expect(EventType.LOGIN_ERROR).user((String)null).error(Errors.USER_NOT_FOUND).assertEvent();
+ }
+
+ private AuthenticatorConfigRepresentation createScriptAuthConfig(String alias, String scriptName, String scriptCodePath, String scriptDescription) throws IOException {
+
+ AuthenticatorConfigRepresentation configRep = new AuthenticatorConfigRepresentation();
+
+ configRep.setAlias(alias);
+ configRep.getConfig().put("scriptCode", IOUtils.toString(getClass().getResourceAsStream(scriptCodePath)));
+ configRep.getConfig().put("scriptName", scriptName);
+ configRep.getConfig().put("scriptDescription", scriptDescription);
+
+ return configRep;
+ }
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js b/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js
new file mode 100644
index 0000000000..0fc10a426a
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/scripts/authenticator-example.js
@@ -0,0 +1,10 @@
+AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
+
+function authenticate(context) {
+ LOG.info(script.name + " --> trace auth for: " + user.username);
+ if (user.username === "fail") {
+ context.failure(AuthenticationFlowError.INVALID_USER);
+ return;
+ }
+ context.success();
+}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
index 4b5b839666..deb90a70b5 100644
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
@@ -2138,9 +2138,20 @@ module.controller('AuthenticationConfigCreateCtrl', function($scope, realm, flow
$scope.realm = realm;
$scope.flow = flow;
$scope.create = true;
- $scope.config = { config: {}};
$scope.configType = configType;
+ var defaultConfig = {};
+ if (configType && Array.isArray(configType.properties)) {
+ for(var i = 0; i < configType.properties.length; i++) {
+ var property = configType.properties[i];
+ if (property && property.name) {
+ defaultConfig[property.name] = property.defaultValue;
+ }
+ }
+ }
+
+ $scope.config = { config: defaultConfig};
+
$scope.$watch(function() {
return $location.path();
}, function() {
@@ -2165,8 +2176,6 @@ module.controller('AuthenticationConfigCreateCtrl', function($scope, realm, flow
//$location.url("/realms");
window.history.back();
};
-
-
});
module.controller('ClientInitialAccessCtrl', function($scope, realm, clientInitialAccess, clientRegTrustedHosts, ClientInitialAccess, ClientRegistrationTrustedHost, Dialog, Notifications, $route, $location) {
diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html
index 97d88762ee..57bbc06de4 100755
--- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html
+++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-component-config.html
@@ -33,7 +33,7 @@
-
+
{{config[option.name]}}
diff --git a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css
index c3a9a64eab..9253c8c839 100755
--- a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css
+++ b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css
@@ -377,6 +377,6 @@ h1 i {
}
.ace_editor {
- height: 400px;
+ height: 600px;
width: 100%;
}
\ No newline at end of file