diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/BearerTokenLoginModule.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/BearerTokenLoginModule.java new file mode 100644 index 0000000000..1efee5bb89 --- /dev/null +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/BearerTokenLoginModule.java @@ -0,0 +1,287 @@ +package org.keycloak.adapters; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.Serializable; +import java.security.Principal; +import java.security.PublicKey; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginException; +import javax.security.auth.spi.LoginModule; + +import org.keycloak.KeycloakPrincipal; +import org.keycloak.RSATokenVerifier; +import org.keycloak.VerificationException; +import org.keycloak.constants.GenericConstants; +import org.keycloak.representations.AccessToken; +import org.keycloak.util.PemUtils; + +/** + * Login module, which allows to authenticate Keycloak access token in environments, which rely on JAAS + *

+ * It expects login based on username and password where username must be equal to "Bearer" and password is keycloak access token. + * + * @author Marek Posolda + */ +public class BearerTokenLoginModule implements LoginModule { + + private final static Logger log = Logger.getLogger("" + BearerTokenLoginModule.class); + + public static final String KEYCLOAK_CONFIG_FILE_OPTION = "keycloak-config-file"; + public static final String REALM_OPTION = "realm"; + public static final String RESOURCE_OPTION = "resource"; + public static final String PUBLIC_KEY_OPTION = "realm-public-key"; + public static final String AUTH_SERVER_URL_OPTION = "auth-server-url"; + public static final String USE_RESOURCE_ROLE_MAPPINGS_OPTION = "use-resource-role-mappings"; + public static final String PRINCIPAL_ATTRIBUTE_OPTION = "principal-attribute"; + + private Subject subject; + private CallbackHandler callbackHandler; + private Auth auth; + + private static volatile KeycloakDeployment deployment; + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options) { + this.subject = subject; + this.callbackHandler = callbackHandler; + + // Static as we don't want to parse config file in each authentication + if (deployment == null) { + KeycloakDeployment kd; + String configFile = (String)options.get(KEYCLOAK_CONFIG_FILE_OPTION); + if (configFile != null) { + InputStream is = loadKeycloakConfigFile(configFile); + kd = KeycloakDeploymentBuilder.build(is); + } else { + // init everything from provided options + String realm = (String) options.get(REALM_OPTION); + if (realm == null) { + throw new IllegalArgumentException("Realm is mandatory if you didn't provide keycloak-config-file"); + } + String authServerUrl = (String) options.get(AUTH_SERVER_URL_OPTION); + String publicKey = (String) options.get(PUBLIC_KEY_OPTION); + if (publicKey == null && authServerUrl == null) { + throw new IllegalArgumentException("Option " + PUBLIC_KEY_OPTION + " is mandatory if you didn't provide keycloak-config-file or auth-server-url to resolver public key"); + } + String resource = (String) options.get(RESOURCE_OPTION); + String resRoleMappings = (String) options.get(USE_RESOURCE_ROLE_MAPPINGS_OPTION); + boolean useResourceRoleMappings = resRoleMappings == null ? false : Boolean.parseBoolean(resRoleMappings); + if (useResourceRoleMappings && resource == null) { + throw new IllegalArgumentException("You want resource-role-mappings, but you didn't provide resource in configuration"); + } + String principalAttribute = (String) options.get(PRINCIPAL_ATTRIBUTE_OPTION); + + kd = new KeycloakDeployment(); + kd.setRealm(realm); + kd.setResourceName(resource); + kd.setUseResourceRoleMappings(useResourceRoleMappings); + kd.setPrincipalAttribute(principalAttribute); + if (publicKey != null) { + try { + PublicKey pk = PemUtils.decodePublicKey(publicKey); + kd.setRealmKey(pk); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + if (kd.getRealmKey() == null) { + new AdapterDeploymentContext().resolveRealmKey(kd); + } + deployment = kd; + } + } + + protected InputStream loadKeycloakConfigFile(String keycloakConfigFile) { + if (keycloakConfigFile.startsWith(GenericConstants.PROTOCOL_CLASSPATH)) { + String classPathLocation = keycloakConfigFile.replace(GenericConstants.PROTOCOL_CLASSPATH, ""); + log.info("Loading config from classpath on location: " + classPathLocation); + // Try current class classloader first + InputStream is = getClass().getClassLoader().getResourceAsStream(classPathLocation); + if (is == null) { + is = Thread.currentThread().getContextClassLoader().getResourceAsStream(classPathLocation); + } + + if (is != null) { + return is; + } else { + throw new RuntimeException("Unable to find config from classpath: " + keycloakConfigFile); + } + } else { + // Fallback to file + try { + log.info("Loading config from file: " + keycloakConfigFile); + return new FileInputStream(keycloakConfigFile); + } catch (FileNotFoundException fnfe) { + log.severe("Config not found on " + keycloakConfigFile); + throw new RuntimeException(fnfe); + } + } + } + + @Override + public boolean login() throws LoginException { + // get username and password + Callback[] callbacks = new Callback[2]; + callbacks[0] = new NameCallback("username"); + callbacks[1] = new PasswordCallback("password", false); + + try { + callbackHandler.handle(callbacks); + String username = ((NameCallback) callbacks[0]).getName(); + char[] tmpPassword = ((PasswordCallback) callbacks[1]).getPassword(); + String password = new String(tmpPassword); + ((PasswordCallback) callbacks[1]).clearPassword(); + + Auth auth = bearerAuth(username, password); + if (auth != null) { + this.auth = auth; + return true; + } else { + return false; + } + } catch (UnsupportedCallbackException uce) { + LoginException le = new LoginException("Error: " + uce.getCallback().toString() + + " not available to gather authentication information from the user"); + le.initCause(uce); + throw le; + } catch (Exception ioe) { + LoginException le = new LoginException(ioe.toString()); + le.initCause(ioe); + throw le; + } + } + + protected Auth bearerAuth(String username, String tokenString) throws VerificationException { + if ("Bearer".equalsIgnoreCase(username)) { + log.fine("Username is expected to be bearer but is " + username + ". Ignoring login module"); + return null; + } + + AccessToken token = RSATokenVerifier.verifyToken(tokenString, deployment.getRealmKey(), deployment.getRealm()); + + boolean verifyCaller; + if (deployment.isUseResourceRoleMappings()) { + verifyCaller = token.isVerifyCaller(deployment.getResourceName()); + } else { + verifyCaller = token.isVerifyCaller(); + } + if (verifyCaller) { + throw new IllegalStateException("VerifyCaller not supported yet in login module"); + } + + RefreshableKeycloakSecurityContext skSession = new RefreshableKeycloakSecurityContext(deployment, null, tokenString, token, null, null, null); + String principalName = AdapterUtils.getPrincipalName(deployment, token); + final KeycloakPrincipal principal = new KeycloakPrincipal(principalName, skSession); + final Set roles = AdapterUtils.getRolesFromSecurityContext(skSession); + return new Auth(principal, roles, tokenString); + } + + @Override + public boolean commit() throws LoginException { + if (auth == null) { + return false; + } + + this.subject.getPrincipals().add(auth.getPrincipal()); + this.subject.getPrivateCredentials().add(auth.getTokenString()); + for (String roleName : auth.getRoles()) { + RolePrincipal rolePrinc = new RolePrincipal(roleName); + this.subject.getPrincipals().add(rolePrinc); + } + + return true; + } + + // Might be needed if subclass wants to setup security context in some env specific way + protected Auth getAuth() { + return auth; + } + + @Override + public boolean abort() throws LoginException { + return true; + } + + @Override + public boolean logout() throws LoginException { + Set principals = new HashSet(subject.getPrincipals()); + for (Principal principal : principals) { + if (principal.getClass().equals(KeycloakPrincipal.class) || principal.getClass().equals(RolePrincipal.class)) { + subject.getPrincipals().remove(principal); + } + } + Set creds = subject.getPrivateCredentials(); + for (Object cred : creds) { + subject.getPrivateCredentials().remove(cred); + } + subject = null; + callbackHandler = null; + return true; + } + + private static class RolePrincipal implements Principal, Serializable { + private static final long serialVersionUID = -5538962177019315447L; + private String roleName = null; + + public RolePrincipal(String roleName) { + this.roleName = roleName; + } + + public boolean equals (Object p) { + if (! (p instanceof RolePrincipal)) + return false; + return getName().equals(((RolePrincipal)p).getName()); + } + + public int hashCode () { + return getName().hashCode(); + } + + public String getName () { + return this.roleName; + } + + public String toString () + { + return getName(); + } + } + + public static class Auth { + private final KeycloakPrincipal principal; + private final Set roles; + private final String tokenString; + + public Auth(KeycloakPrincipal principal, Set roles, String accessToken) { + this.principal = principal; + this.roles = roles; + this.tokenString = accessToken; + } + + public KeycloakPrincipal getPrincipal() { + return principal; + } + + public Set getRoles() { + return roles; + } + + public String getTokenString() { + return tokenString; + } + } +}