KEYCLOAK-3491 Revise Scripting Support

Refactored the scripting infrastructure and added documentation.
Added tests and an authenticator template in JavaScript for a quickstart.
Increased height of ace code editor to 600px to avoid scrolling.
This commit is contained in:
Thomas Darimont 2016-08-29 18:20:13 +02:00
parent 0addfeaa75
commit 8e113384aa
18 changed files with 693 additions and 158 deletions

View file

@ -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 <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
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.
*/

View file

@ -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 <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
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> T getInterface(Class<T> clazz) {
return getInvocableEngine().getInterface(clazz);
}
@Override
public <T> T getInterface(Object thiz, Class<T> 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;
}
}

View file

@ -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 <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
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> T getInterface(Class<T> clazz) {
return getInvocableEngine().getInterface(clazz);
}
@Override
public <T> T getInterface(Object thiz, Class<T> 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;
}
}

View file

@ -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 <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
public class Script implements ScriptModel {

View file

@ -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}.
*
* <p>Used by {@link ScriptingProvider}</p>
* @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
@FunctionalInterface
public interface ScriptBindingsConfigurer {
/**
* A default {@link ScriptBindingsConfigurer} leaves the Bindings empty.
* A default {@link ScriptBindingsConfigurer} that provides no Bindings.
*/
ScriptBindingsConfigurer EMPTY = new ScriptBindingsConfigurer() {

View file

@ -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);
}
}

View file

@ -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}.
* <p>The {@code InvocableScript} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}</p>
* Returns an {@link InvocableScriptAdapter} based on the given {@link ScriptModel}.
* <p>The {@code InvocableScriptAdapter} wraps a dedicated {@link ScriptEngine} that was populated with the provided {@link ScriptBindingsConfigurer}</p>
*
* @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);
}

View file

@ -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.
* <p>scripts must provide </p>
* <p>
* Scripts must at least provide one of the following functions:
* <ol>
* <li>{@code authenticate(..)} which is called from {@link Authenticator#authenticate(AuthenticationFlowContext)}</li>
* <li>{@code action(..)} which is called from {@link Authenticator#action(AuthenticationFlowContext)}</li>
* </ol>
* </p>
* <p>
* 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.
* <ol>
* <li>{@code script} the {@link ScriptModel} to access script metadata</li>
* <li>{@code realm} the {@link RealmModel}</li>
* <li>{@code user} the current {@link UserModel}</li>
* <li>{@code session} the active {@link KeycloakSession}</li>
* <li>{@code httpRequest} the current {@link org.jboss.resteasy.spi.HttpRequest}</li>
* <li>{@code LOG} a {@link org.jboss.logging.Logger} scoped to {@link ScriptBasedAuthenticator}/li>
* </ol>
* </p>
* <p>
* Additional context information can be extracted from the {@code context} argument passed to the {@code authenticate(context)}
* or {@code action(context)} function.
* <p>
* An example {@link ScriptBasedAuthenticator} definition could look as follows:
* <pre>
* {@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();
* }
* }
* </pre>
*
* @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
@ -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<String, String> 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
}

View file

@ -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);
}

View file

@ -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) {
if (scriptModel == null) {
throw new IllegalArgumentException("script must not be null");
}
@Override
public InvocableScript prepareScript(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) {
if (script == null) {
throw new NullPointerException("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());
}

View file

@ -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();
}
}

View file

@ -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();
}

View file

@ -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.");

View file

@ -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 <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
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;
}
}

View file

@ -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();
}

View file

@ -2118,9 +2118,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() {
@ -2145,8 +2156,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) {

View file

@ -33,7 +33,7 @@
</div>
<div class="col-md-6" data-ng-show="option.type == 'Script'">
<div ng-model="config[option.name][0]" placeholder="Enter your script..." ui-ace="{ useWrapMode: true, showGutter: true, theme:'github', mode: 'javascript'}">
<div ng-model="config[option.name]" placeholder="Enter your script..." ui-ace="{ useWrapMode: true, showGutter: true, theme:'github', mode: 'javascript'}">
{{config[option.name]}}
</div>
</div>

View file

@ -377,6 +377,6 @@ h1 i {
}
.ace_editor {
height: 400px;
height: 600px;
width: 100%;
}