From db9b1bcb21d4b3fb1471de2e3471ee3b3d0712b8 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 28 Jul 2017 16:15:39 -0400 Subject: [PATCH 1/6] token exchange --- adapters/oidc/cli-sso/README.md | 35 +++ adapters/oidc/cli-sso/login.sh | 10 + adapters/oidc/cli-sso/logout.sh | 9 + adapters/oidc/cli-sso/pom.xml | 84 ++++++ .../keycloak/adapters/KeycloakCliSsoMain.java | 45 +++ .../adapters/installed/KeycloakCliSso.java | 266 ++++++++++++++++++ .../adapters/installed/KeycloakInstalled.java | 152 +++++++++- adapters/oidc/pom.xml | 1 + .../java/org/keycloak/OAuth2Constants.java | 12 + .../java/org/keycloak/events/EventType.java | 4 +- .../oidc/endpoints/TokenEndpoint.java | 100 ++++++- .../AdminPermissionManagement.java | 3 + .../admin/permissions/MgmtPermissions.java | 1 + .../keycloak/testsuite/util/OAuthClient.java | 45 +++ .../admin/FineGrainAdminUnitTest.java | 76 +++++ .../testsuite/oauth/TokenExchangeTest.java | 179 ++++++++++++ 16 files changed, 1007 insertions(+), 15 deletions(-) create mode 100755 adapters/oidc/cli-sso/README.md create mode 100755 adapters/oidc/cli-sso/login.sh create mode 100644 adapters/oidc/cli-sso/logout.sh create mode 100755 adapters/oidc/cli-sso/pom.xml create mode 100644 adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java create mode 100644 adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java create mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java diff --git a/adapters/oidc/cli-sso/README.md b/adapters/oidc/cli-sso/README.md new file mode 100755 index 0000000000..5409ec630a --- /dev/null +++ b/adapters/oidc/cli-sso/README.md @@ -0,0 +1,35 @@ +CLI Single Sign On +=================================== + +This java-based utility is meant for providing Keycloak integration to +command line applications that are either written in Java or another language. The +idea is that the Java app provided by this utility performs a login for a specific +client, parses responses, and exports an access token as an environment variable +that can be used by the command line utility you are accessing. + +So, the idea is that you create a login script as follows: + + #!/bin/sh + export KC_TOKEN_RESPONSE=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` + export KC_ACCESS_TOKEN=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar token` + + +You pass in the parameters for connecting to Keycloak through Java system properties. The command `login` will perform the following steps: +1. Look to see if `KEYCLOAK_TOKEN_RESPONSE` is set. If so, it will check expiration information and perform a refresh token call if the token needs refreshing. +2. If `KEYCLOAK_TOKEN_RESPONSE` is not set, then it will use open a browser and perform a login to obtain an OAuth token response back. +3. The output to STDOUT will be the oauth accesss token response. This will be stored by the script in an environment variable. +4. The 2nd line of the script extracts the access token from the response json and stuffs it in an environment variable that will be used by the CLI application. + +So, the idea is that you wrap your CLI application within this script and extract the access token +from an environment variable. For example, if our CLI application is a C program named `mycli` the script would look like this: + + #!/bin/sh + export KC_TOKEN_RESPONSE=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` + export KC_ACCESS_TOKEN=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar token` + mycli $@ + +You would invoke it as follows: + + $ . mycli.sh + +Using the `.` so that the environment variables get exported. diff --git a/adapters/oidc/cli-sso/login.sh b/adapters/oidc/cli-sso/login.sh new file mode 100755 index 0000000000..ff33a015e9 --- /dev/null +++ b/adapters/oidc/cli-sso/login.sh @@ -0,0 +1,10 @@ +#!/bin/sh +export KC_AUTH_SERVER=http://localhost:8080/auth +export KC_REALM=master +export KC_CLIENT=cli + +export KC_ACCESS_TOKEN=`java -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` + + + + diff --git a/adapters/oidc/cli-sso/logout.sh b/adapters/oidc/cli-sso/logout.sh new file mode 100644 index 0000000000..ca99f88b76 --- /dev/null +++ b/adapters/oidc/cli-sso/logout.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +java -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar logout + +unset KC_ACCESS_TOKEN + + + + diff --git a/adapters/oidc/cli-sso/pom.xml b/adapters/oidc/cli-sso/pom.xml new file mode 100755 index 0000000000..216c3b794e --- /dev/null +++ b/adapters/oidc/cli-sso/pom.xml @@ -0,0 +1,84 @@ + + + + + + keycloak-parent + org.keycloak + 3.3.0.CR1-SNAPSHOT + ../../../pom.xml + + 4.0.0 + + keycloak-cli-sso + Keycloak CLI SSO Framework + + + + + org.keycloak + keycloak-installed-adapter + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.0.0 + + + + org.keycloak.adapters.KeycloakCliSsoMain + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + package + + shade + + + + + + + + diff --git a/adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java b/adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java new file mode 100644 index 0000000000..3aaeb9b18a --- /dev/null +++ b/adapters/oidc/cli-sso/src/main/java/org/keycloak/adapters/KeycloakCliSsoMain.java @@ -0,0 +1,45 @@ +/* + * 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.adapters; + +import org.keycloak.adapters.installed.KeycloakCliSso; +import org.keycloak.adapters.installed.KeycloakInstalled; +import org.keycloak.common.util.Time; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class KeycloakCliSsoMain extends KeycloakCliSso { + + public static void main(String[] args) throws Exception { + new KeycloakCliSsoMain().mainCmd(args); + } +} diff --git a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java new file mode 100644 index 0000000000..3c1d3655a1 --- /dev/null +++ b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakCliSso.java @@ -0,0 +1,266 @@ +/* + * 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.adapters.installed; + +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.ServerRequest; +import org.keycloak.common.util.Time; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * + * + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class KeycloakCliSso { + + public void mainCmd(String[] args) throws Exception { + if (args.length != 1) { + printHelp(); + return; + } + + if (args[0].equalsIgnoreCase("login")) { + login(); + } else if (args[0].equalsIgnoreCase("login-manual")) { + loginManual(); + } else if (args[0].equalsIgnoreCase("token")) { + token(); + } else if (args[0].equalsIgnoreCase("logout")) { + logout(); + } else if (args[0].equalsIgnoreCase("env")) { + System.out.println(System.getenv().toString()); + } else { + printHelp(); + } + } + + + public void printHelp() { + System.err.println("Commands:"); + System.err.println(" login - login with desktop browser if available, otherwise do manual login. Output is access token."); + System.err.println(" login-manual - manual login"); + System.err.println(" token - print access token if logged in"); + System.err.println(" logout - logout."); + System.exit(1); + } + + public AdapterConfig getConfig() { + String url = System.getProperty("KEYCLOAK_AUTH_SERVER"); + if (url == null) { + System.err.println("KEYCLOAK_AUTH_SERVER property not set"); + System.exit(1); + } + String realm = System.getProperty("KEYCLOAK_REALM"); + if (realm == null) { + System.err.println("KEYCLOAK_REALM property not set"); + System.exit(1); + + } + String client = System.getProperty("KEYCLOAK_CLIENT"); + if (client == null) { + System.err.println("KEYCLOAK_CLIENT property not set"); + System.exit(1); + } + String secret = System.getProperty("KEYCLOAK_CLIENT_SECRET"); + + + + AdapterConfig config = new AdapterConfig(); + config.setAuthServerUrl(url); + config.setRealm(realm); + config.setResource(client); + config.setSslRequired("external"); + if (secret != null) { + Map creds = new HashMap<>(); + creds.put("secret", secret); + config.setCredentials(creds); + } else { + config.setPublicClient(true); + } + return config; + } + + public boolean checkToken() throws Exception { + String token = getTokenResponse(); + if (token == null) return false; + + + if (token != null) { + Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); + if (m.find()) { + String json = m.group(0); + try { + AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); + if (Time.currentTime() < tokenResponse.getExpiresIn()) { + return true; + } + AdapterConfig config = getConfig(); + KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config)); + installed.refreshToken(tokenResponse.getRefreshToken()); + processResponse(installed); + return true; + } catch (Exception e) { + System.err.println("Error processing existing token"); + e.printStackTrace(); + } + + } + } + return false; + + } + + private String getTokenResponse() throws IOException { + String token = null; + File tokenFile = getTokenFilePath(); + if (tokenFile.exists()) { + FileInputStream fis = new FileInputStream(tokenFile); + byte[] data = new byte[(int) tokenFile.length()]; + fis.read(data); + fis.close(); + token = new String(data, "UTF-8"); + } + return token; + } + + public void token() throws Exception { + String token = getTokenResponse(); + if (token == null) { + System.err.println("There is no token for client"); + System.exit(1); + } else { + Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); + if (m.find()) { + String json = m.group(0); + try { + AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); + if (Time.currentTime() < tokenResponse.getExpiresIn()) { + System.out.println(tokenResponse.getToken()); + return; + } else { + System.err.println("token in response file is expired"); + System.exit(1); + } + } catch (Exception e) { + System.err.println("Failure processing token response file"); + e.printStackTrace(); + System.exit(1); + } + } else { + System.err.println("Could not find json within token response file"); + System.exit(1); + } + } + } + + public void login() throws Exception { + if (checkToken()) return; + AdapterConfig config = getConfig(); + KeycloakInstalled installed = new KeycloakInstalled(KeycloakDeploymentBuilder.build(config)); + installed.login(); + processResponse(installed); + } + + public String getHome() { + String home = System.getenv("HOME"); + if (home == null) { + home = System.getProperty("HOME"); + if (home == null) { + home = Paths.get("").toAbsolutePath().normalize().toString(); + } + } + return home; + } + + public File getTokenDirectory() { + return Paths.get(getHome(), System.getProperty("basepath", ".keycloak-sso"), System.getProperty("KEYCLOAK_REALM")).toFile(); + } + + public File getTokenFilePath() { + return Paths.get(getHome(), System.getProperty("basepath", ".keycloak-sso"), System.getProperty("KEYCLOAK_REALM"), System.getProperty("KEYCLOAK_CLIENT") + ".json").toFile(); + } + + private void processResponse(KeycloakInstalled installed) throws IOException { + AccessTokenResponse tokenResponse = installed.getTokenResponse(); + tokenResponse.setExpiresIn(Time.currentTime() + tokenResponse.getExpiresIn()); + tokenResponse.setIdToken(null); + String output = JsonSerialization.writeValueAsString(tokenResponse); + getTokenDirectory().mkdirs(); + FileOutputStream fos = new FileOutputStream(getTokenFilePath()); + fos.write(output.getBytes("UTF-8")); + fos.flush(); + fos.close(); + System.out.println(tokenResponse.getToken()); + } + + public void loginManual() throws Exception { + if (checkToken()) return; + AdapterConfig config = getConfig(); + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config); + KeycloakInstalled installed = new KeycloakInstalled(deployment); + installed.loginManual(); + processResponse(installed); + } + + public void logout() throws Exception { + String token = getTokenResponse(); + if (token != null) { + Matcher m = Pattern.compile("\\{.*\\}\\z").matcher(token); + if (m.find()) { + String json = m.group(0); + try { + AccessTokenResponse tokenResponse = JsonSerialization.readValue(json, AccessTokenResponse.class); + if (Time.currentTime() > tokenResponse.getExpiresIn()) { + System.err.println("Login is expired"); + System.exit(1); + } + AdapterConfig config = getConfig(); + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config); + ServerRequest.invokeLogout(deployment, tokenResponse.getRefreshToken()); + for (File fp : getTokenDirectory().listFiles()) fp.delete(); + System.out.println("logout complete"); + } catch (Exception e) { + System.err.println("Failure processing token response file"); + e.printStackTrace(); + System.exit(1); + } + } else { + System.err.println("Could not find json within token response file"); + System.exit(1); + } + } else { + System.err.println("Not logged in"); + System.exit(1); + } + } +} diff --git a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java index 9834fe24de..61ca06e520 100644 --- a/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java +++ b/adapters/oidc/installed/src/main/java/org/keycloak/adapters/installed/KeycloakInstalled.java @@ -17,6 +17,7 @@ package org.keycloak.adapters.installed; +import org.apache.commons.codec.Charsets; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.adapters.KeycloakDeployment; @@ -24,6 +25,7 @@ import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.ServerRequest; import org.keycloak.adapters.rotation.AdapterRSATokenVerifier; import org.keycloak.common.VerificationException; +import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.representations.AccessToken; @@ -43,6 +45,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.URI; import java.net.URISyntaxException; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -51,6 +54,11 @@ import java.util.concurrent.TimeUnit; */ public class KeycloakInstalled { + public interface HttpResponseWriter { + void success(PrintWriter pw, KeycloakInstalled ki); + void failure(PrintWriter pw, KeycloakInstalled ki); + } + private static final String KEYCLOAK_JSON = "META-INF/keycloak.json"; private KeycloakDeployment deployment; @@ -59,12 +67,18 @@ public class KeycloakInstalled { LOGGED_MANUAL, LOGGED_DESKTOP } + private AccessTokenResponse tokenResponse; private String tokenString; private String idTokenString; private IDToken idToken; private AccessToken token; private String refreshToken; private Status status; + private Locale locale; + private HttpResponseWriter loginResponseWriter; + private HttpResponseWriter logoutResponseWriter; + + public KeycloakInstalled() { InputStream config = Thread.currentThread().getContextClassLoader().getResourceAsStream(KEYCLOAK_JSON); @@ -75,6 +89,92 @@ public class KeycloakInstalled { deployment = KeycloakDeploymentBuilder.build(config); } + public KeycloakInstalled(KeycloakDeployment deployment) { + this.deployment = deployment; + } + + private static HttpResponseWriter defaultLoginWriter = new HttpResponseWriter() { + @Override + public void success(PrintWriter pw, KeycloakInstalled ki) { + pw.println("HTTP/1.1 200 OK"); + pw.println("Content-Type: text/html"); + pw.println(); + pw.println("

Login completed.

"); + pw.println("This browser will remain logged in until you close it, logout, or the session expires."); + pw.println("
"); + pw.flush(); + + } + + @Override + public void failure(PrintWriter pw, KeycloakInstalled ki) { + pw.println("HTTP/1.1 200 OK"); + pw.println("Content-Type: text/html"); + pw.println(); + pw.println("

Login attempt failed.

"); + pw.println("
"); + pw.flush(); + + } + }; + private static HttpResponseWriter defaultLogoutWriter = new HttpResponseWriter() { + @Override + public void success(PrintWriter pw, KeycloakInstalled ki) { + pw.println("HTTP/1.1 200 OK"); + pw.println("Content-Type: text/html"); + pw.println(); + pw.println("

Logout completed.

"); + pw.println("You may close this browser tab."); + pw.println("
"); + pw.flush(); + + } + + @Override + public void failure(PrintWriter pw, KeycloakInstalled ki) { + pw.println("HTTP/1.1 200 OK"); + pw.println("Content-Type: text/html"); + pw.println(); + pw.println("

Logout failed.

"); + pw.println("You may close this browser tab."); + pw.println("
"); + pw.flush(); + + } + }; + + public HttpResponseWriter getLoginResponseWriter() { + if (loginResponseWriter == null) { + return defaultLoginWriter; + } else { + return loginResponseWriter; + } + } + + public HttpResponseWriter getLogoutResponseWriter() { + if (logoutResponseWriter == null) { + return defaultLogoutWriter; + } else { + return logoutResponseWriter; + } + } + + public void setLoginResponseWriter(HttpResponseWriter loginResponseWriter) { + this.loginResponseWriter = loginResponseWriter; + } + + public void setLogoutResponseWriter(HttpResponseWriter logoutResponseWriter) { + this.logoutResponseWriter = logoutResponseWriter; + } + + public Locale getLocale() { + return locale; + } + + public void setLocale(Locale locale) { + this.locale = locale; + } + public void login() throws IOException, ServerRequest.HttpFailure, VerificationException, InterruptedException, OAuthErrorException, URISyntaxException { if (isDesktopSupported()) { loginDesktop(); @@ -108,19 +208,22 @@ public class KeycloakInstalled { } public void loginDesktop() throws IOException, VerificationException, OAuthErrorException, URISyntaxException, ServerRequest.HttpFailure, InterruptedException { - CallbackListener callback = new CallbackListener(); + CallbackListener callback = new CallbackListener(getLoginResponseWriter()); callback.start(); String redirectUri = "http://localhost:" + callback.server.getLocalPort(); String state = UUID.randomUUID().toString(); - String authUrl = deployment.getAuthUrl().clone() + KeycloakUriBuilder builder = deployment.getAuthUrl().clone() .queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE) .queryParam(OAuth2Constants.CLIENT_ID, deployment.getResourceName()) .queryParam(OAuth2Constants.REDIRECT_URI, redirectUri) .queryParam(OAuth2Constants.STATE, state) - .queryParam(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID) - .build().toString(); + .queryParam(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID); + if (locale != null) { + builder.queryParam(OAuth2Constants.UI_LOCALES_PARAM, locale.getLanguage()); + } + String authUrl = builder.build().toString(); Desktop.getDesktop().browse(new URI(authUrl)); @@ -144,7 +247,7 @@ public class KeycloakInstalled { } private void logoutDesktop() throws IOException, URISyntaxException, InterruptedException { - CallbackListener callback = new CallbackListener(); + CallbackListener callback = new CallbackListener(getLogoutResponseWriter()); callback.start(); String redirectUri = "http://localhost:" + callback.server.getLocalPort(); @@ -167,9 +270,6 @@ public class KeycloakInstalled { } public void loginManual(PrintStream printer, Reader reader) throws IOException, ServerRequest.HttpFailure, VerificationException { - CallbackListener callback = new CallbackListener(); - callback.start(); - String redirectUri = "urn:ietf:wg:oauth:2.0:oob"; String authUrl = deployment.getAuthUrl().clone() @@ -208,7 +308,14 @@ public class KeycloakInstalled { parseAccessToken(tokenResponse); } + public void refreshToken(String refreshToken) throws IOException, ServerRequest.HttpFailure, VerificationException { + AccessTokenResponse tokenResponse = ServerRequest.invokeRefresh(deployment, refreshToken); + parseAccessToken(tokenResponse); + + } + private void parseAccessToken(AccessTokenResponse tokenResponse) throws VerificationException { + this.tokenResponse = tokenResponse; tokenString = tokenResponse.getToken(); refreshToken = tokenResponse.getRefreshToken(); idTokenString = tokenResponse.getIdToken(); @@ -240,6 +347,10 @@ public class KeycloakInstalled { return refreshToken; } + public AccessTokenResponse getTokenResponse() { + return tokenResponse; + } + public boolean isDesktopSupported() { return Desktop.isDesktopSupported(); } @@ -248,6 +359,8 @@ public class KeycloakInstalled { return deployment; } + + private void processCode(String code, String redirectUri) throws IOException, ServerRequest.HttpFailure, VerificationException { AccessTokenResponse tokenResponse = ServerRequest.invokeAccessCodeToToken(deployment, code, redirectUri, null); parseAccessToken(tokenResponse); @@ -269,6 +382,7 @@ public class KeycloakInstalled { return sb.toString(); } + public class CallbackListener extends Thread { private ServerSocket server; @@ -283,14 +397,19 @@ public class KeycloakInstalled { private String state; - public CallbackListener() throws IOException { + private Socket socket; + + private HttpResponseWriter writer; + + public CallbackListener(HttpResponseWriter writer) throws IOException { + this.writer = writer; server = new ServerSocket(0); } @Override public void run() { try { - Socket socket = server.accept(); + socket = server.accept(); BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream())); String request = br.readLine(); @@ -314,10 +433,15 @@ public class KeycloakInstalled { } } - PrintWriter pw = new PrintWriter(new OutputStreamWriter(socket.getOutputStream())); - pw.println("Please close window and return to application"); - pw.flush(); + OutputStreamWriter out = new OutputStreamWriter(socket.getOutputStream()); + PrintWriter pw = new PrintWriter(out); + if (error == null) { + writer.success(pw, KeycloakInstalled.this); + } else { + writer.failure(pw, KeycloakInstalled.this); + } + pw.flush(); socket.close(); } catch (IOException e) { errorException = e; @@ -328,6 +452,8 @@ public class KeycloakInstalled { } catch (IOException e) { } } + } + } diff --git a/adapters/oidc/pom.xml b/adapters/oidc/pom.xml index f9e9b8a491..9207401a1c 100755 --- a/adapters/oidc/pom.xml +++ b/adapters/oidc/pom.xml @@ -34,6 +34,7 @@ adapter-core as7-eap6 installed + cli-sso jaxrs-oauth-client jetty js diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 234b632b04..70105f7074 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -50,6 +50,7 @@ public interface OAuth2Constants { String AUTHORIZATION_CODE = "authorization_code"; + String IMPLICIT = "implicit"; String PASSWORD = "password"; @@ -92,6 +93,17 @@ public interface OAuth2Constants { String PKCE_METHOD_PLAIN = "plain"; String PKCE_METHOD_S256 = "S256"; + String TOKEN_EXCHANGE_GRANT_TYPE="urn:ietf:params:oauth:grant-type:token-exchange"; + String AUDIENCE="audience"; + String SUBJECT_TOKEN="subject_token"; + String SUBJECT_TOKEN_TYPE="subject_token_type"; + String ACCESS_TOKEN_TYPE="urn:ietf:params:oauth:token-type:access_token"; + String REFRESH_TOKEN_TYPE="urn:ietf:params:oauth:token-type:refresh_token"; + String JWT_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt"; + String ID_TOKEN_TYPE="urn:ietf:params:oauth:token-type:id_token"; + String TOKEN_EXCHANGEABLE ="token-exchangeable"; + + } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 920646fa58..b48e2433a7 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -123,7 +123,9 @@ public enum EventType { CLIENT_DELETE_ERROR(true), CLIENT_INITIATED_ACCOUNT_LINKING(true), - CLIENT_INITIATED_ACCOUNT_LINKING_ERROR(true); + CLIENT_INITIATED_ACCOUNT_LINKING_ERROR(true), + TOKEN_EXCHANGE(true), + TOKEN_EXCHANGE_ERROR(true); private boolean saveByDefault; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 14d557068b..62dd3c3406 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -36,6 +36,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -52,6 +53,7 @@ import org.keycloak.services.managers.ClientManager; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.resources.Cors; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.TokenUtil; @@ -80,7 +82,7 @@ public class TokenEndpoint { private Map clientAuthAttributes; private enum Action { - AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS + AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE } // https://tools.ietf.org/html/rfc7636#section-4.2 @@ -134,6 +136,8 @@ public class TokenEndpoint { return buildResourceOwnerPasswordCredentialsGrant(); case CLIENT_CREDENTIALS: return buildClientCredentialsGrant(); + case TOKEN_EXCHANGE: + return buildTokenExchange(); } throw new RuntimeException("Unknown action " + action); @@ -197,6 +201,10 @@ public class TokenEndpoint { } else if (grantType.equals(OAuth2Constants.CLIENT_CREDENTIALS)) { event.event(EventType.CLIENT_LOGIN); action = Action.CLIENT_CREDENTIALS; + } else if (grantType.equals(OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)) { + event.event(EventType.TOKEN_EXCHANGE); + action = Action.TOKEN_EXCHANGE; + } else { throw new ErrorResponseException(Errors.INVALID_REQUEST, "Invalid " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST); } @@ -544,6 +552,96 @@ public class TokenEndpoint { return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); } + public Response buildTokenExchange() { + event.detail(Details.AUTH_METHOD, "oauth_credentials"); + + String scope = formParams.getFirst(OAuth2Constants.SCOPE); + String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN); + String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, uriInfo, clientConnection, true, true, false, subjectToken, headers); + if (authResult == null) { + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.BAD_REQUEST); + } + + if (authResult.getToken().getAudience() == null || authResult.getToken().getAudience().length > 1 + || !client.getClientId().equals(authResult.getToken().getAudience()[0]) ) { + + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Cannot exchange token from different client", Response.Status.BAD_REQUEST); + } + + String audience = formParams.getFirst(OAuth2Constants.AUDIENCE); + if (audience == null) { + event.error(Errors.INVALID_REQUEST); + throw new ErrorResponseException("invalid_audience", "No audience specified", Response.Status.BAD_REQUEST); + + } + ClientModel targetClient = null; + if (audience != null) { + targetClient = realm.getClientByClientId(audience); + } + if (targetClient == null) { + event.error(Errors.INVALID_CLIENT); + throw new ErrorResponseException("invalid_client", "Client authentication ended, but client is null", Response.Status.BAD_REQUEST); + } + + if (targetClient.isConsentRequired()) { + event.error(Errors.CONSENT_DENIED); + throw new ErrorResponseException(OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST); + } + + boolean allowed = false; + UserModel serviceAccount = session.users().getServiceAccount(client); + if (serviceAccount != null) { + RoleModel exchangeable = targetClient.getRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().getRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + allowed = (exchangeable != null && serviceAccount.hasRole(exchangeable)) || (realmExchangeable != null && serviceAccount.hasRole(realmExchangeable)); + + } + + if (!allowed) { + event.error(Errors.NOT_ALLOWED); + throw new ErrorResponseException(OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + + } + + AuthenticationSessionModel authSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, targetClient, false); + authSession.setAuthenticatedUser(authResult.getUser()); + authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); + authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scope); + + UserSessionModel userSession = authResult.getSession(); + event.session(userSession); + + AuthenticationManager.setRolesAndMappersInSession(authSession); + AuthenticatedClientSessionModel clientSession = TokenManager.attachAuthenticationSession(session, userSession, authSession); + + // Notes about client details + userSession.setNote(ServiceAccountConstants.CLIENT_ID, client.getClientId()); + userSession.setNote(ServiceAccountConstants.CLIENT_HOST, clientConnection.getRemoteHost()); + userSession.setNote(ServiceAccountConstants.CLIENT_ADDRESS, clientConnection.getRemoteAddr()); + + updateUserSessionFromClientAuth(userSession); + + TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, targetClient, event, session, userSession, clientSession) + .generateAccessToken() + .generateRefreshToken(); + + String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE); + if (TokenUtil.isOIDCRequest(scopeParam)) { + responseBuilder.generateIDToken(); + } + + AccessTokenResponse res = responseBuilder.build(); + + event.success(); + + return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); + } + + // https://tools.ietf.org/html/rfc7636#section-4.1 private boolean isValidPkceCodeVerifier(String codeVerifier) { if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissionManagement.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissionManagement.java index 2a94132993..7df5b5e073 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissionManagement.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissionManagement.java @@ -18,6 +18,7 @@ package org.keycloak.services.resources.admin.permissions; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.models.ClientModel; /** * @author Bill Burke @@ -27,6 +28,8 @@ public interface AdminPermissionManagement { public static final String MANAGE_SCOPE = "manage"; public static final String VIEW_SCOPE = "view"; + ClientModel getRealmManagementClient(); + AuthorizationProvider authz(); RolePermissionManagement roles(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java index 400cee1fe6..449530cdf4 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java @@ -122,6 +122,7 @@ class MgmtPermissions implements AdminPermissionEvaluator, AdminPermissionManage this.identity = new UserModelIdentity(realm, admin); } + @Override public ClientModel getRealmManagementClient() { ClientModel client = null; if (realm.getName().equals(Config.getAdminRealm())) { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 207a317e96..a98724f5d1 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -389,6 +389,51 @@ public class OAuthClient { } } + public AccessTokenResponse doTokenExchange(String realm, String token, String targetAudience, + String clientId, String clientSecret) throws Exception { + CloseableHttpClient client = newCloseableHttpClient(); + try { + HttpPost post = new HttpPost(getResourceOwnerPasswordCredentialGrantUrl(realm)); + + List parameters = new LinkedList(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)); + parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, token)); + parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)); + parameters.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, targetAudience)); + + if (clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + } else { + parameters.add(new BasicNameValuePair("client_id", clientId)); + + } + + if (clientSessionState != null) { + parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, clientSessionState)); + } + if (clientSessionHost != null) { + parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost)); + } + if (scope != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scope)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AccessTokenResponse(client.execute(post)); + } finally { + closeClient(client); + } + } + + public JSONWebKeySet doCertsRequest(String realm) throws Exception { CloseableHttpClient client = new DefaultHttpClient(); try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java index 02121bbf48..8bfc9561f4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java @@ -51,6 +51,7 @@ import org.keycloak.testsuite.runonserver.RunOnServerDeployment; import org.keycloak.testsuite.util.AdminClientUtil; import javax.ws.rs.ClientErrorException; +import javax.ws.rs.core.Response; import java.util.LinkedList; import java.util.List; @@ -741,6 +742,81 @@ public class FineGrainAdminUnitTest extends AbstractKeycloakTest { testingClient.server().run(FineGrainAdminUnitTest::invokeDelete); } + // KEYCLOAK-5211 + @Test + public void testCreateRealmCreateClient() throws Exception { + ClientRepresentation rep = new ClientRepresentation(); + rep.setName("fullScopedClient"); + rep.setClientId("fullScopedClient"); + rep.setFullScopeAllowed(true); + rep.setSecret("618268aa-51e6-4e64-93c4-3c0bc65b8171"); + rep.setProtocol("openid-connect"); + rep.setPublicClient(false); + rep.setEnabled(true); + adminClient.realm("master").clients().create(rep); + + Keycloak realmClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), + "master", "admin", "admin", "fullScopedClient", "618268aa-51e6-4e64-93c4-3c0bc65b8171"); + + RealmRepresentation newRealm=new RealmRepresentation(); + newRealm.setRealm("anotherRealm"); + newRealm.setId("anotherRealm"); + newRealm.setEnabled(true); + realmClient.realms().create(newRealm); + + ClientRepresentation newClient = new ClientRepresentation(); + + newClient.setName("newClient"); + newClient.setClientId("newClient"); + newClient.setFullScopeAllowed(true); + newClient.setSecret("secret"); + newClient.setProtocol("openid-connect"); + newClient.setPublicClient(false); + newClient.setEnabled(true); + Response response = realmClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(403, response.getStatus()); + + realmClient.close(); + realmClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), + "master", "admin", "admin", "fullScopedClient", "618268aa-51e6-4e64-93c4-3c0bc65b8171"); + response = realmClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(201, response.getStatus()); + + + } + + // KEYCLOAK-5211 + @Test + public void testCreateRealmCreateClientWithMaster() throws Exception { + ClientRepresentation rep = new ClientRepresentation(); + rep.setName("fullScopedClient"); + rep.setClientId("fullScopedClient"); + rep.setFullScopeAllowed(true); + rep.setSecret("618268aa-51e6-4e64-93c4-3c0bc65b8171"); + rep.setProtocol("openid-connect"); + rep.setPublicClient(false); + rep.setEnabled(true); + adminClient.realm("master").clients().create(rep); + + RealmRepresentation newRealm=new RealmRepresentation(); + newRealm.setRealm("anotherRealm"); + newRealm.setId("anotherRealm"); + newRealm.setEnabled(true); + adminClient.realms().create(newRealm); + + ClientRepresentation newClient = new ClientRepresentation(); + + newClient.setName("newClient"); + newClient.setClientId("newClient"); + newClient.setFullScopeAllowed(true); + newClient.setSecret("secret"); + newClient.setProtocol("openid-connect"); + newClient.setPublicClient(false); + newClient.setEnabled(true); + Response response = adminClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(201, response.getStatus()); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java new file mode 100755 index 0000000000..5a6e3bb211 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java @@ -0,0 +1,179 @@ +/* + * 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.oauth; + +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.DefaultHttpClient; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.admin.FineGrainAdminUnitTest; +import org.keycloak.testsuite.runonserver.RunOnServerDeployment; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.RealmManager; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.testsuite.util.UserManager; +import org.keycloak.util.JsonSerialization; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; + +/** + * @author Stian Thorgersen + */ +public class TokenExchangeTest extends AbstractKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Deployment + public static WebArchive deploy() { + return RunOnServerDeployment.create(TokenExchangeTest.class); + } + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation testRealmRep = new RealmRepresentation(); + testRealmRep.setId(TEST); + testRealmRep.setRealm(TEST); + testRealmRep.setEnabled(true); + testRealms.add(testRealmRep); + } + + public static void setupRealm(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(TEST); + RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().addRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + + RoleModel exampleRole = realm.addRole("example"); + + ClientModel target = realm.addClient("target"); + target.setDirectAccessGrantsEnabled(true); + target.setEnabled(true); + target.setSecret("secret"); + target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + target.setFullScopeAllowed(false); + target.addScopeMapping(exampleRole); + RoleModel targetExchangeable = target.addRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + + target = realm.addClient("realm-exchanger"); + target.setClientId("realm-exchanger"); + target.setDirectAccessGrantsEnabled(true); + target.setEnabled(true); + target.setSecret("secret"); + target.setServiceAccountsEnabled(true); + target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + target.setFullScopeAllowed(false); + new org.keycloak.services.managers.ClientManager(new org.keycloak.services.managers.RealmManager(session)).enableServiceAccount(target); + session.users().getServiceAccount(target).grantRole(realmExchangeable); + + target = realm.addClient("client-exchanger"); + target.setClientId("client-exchanger"); + target.setDirectAccessGrantsEnabled(true); + target.setEnabled(true); + target.setSecret("secret"); + target.setServiceAccountsEnabled(true); + target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + target.setFullScopeAllowed(false); + new org.keycloak.services.managers.ClientManager(new org.keycloak.services.managers.RealmManager(session)).enableServiceAccount(target); + session.users().getServiceAccount(target).grantRole(targetExchangeable); + + target = realm.addClient("account-not-allowed"); + target.setClientId("account-not-allowed"); + target.setDirectAccessGrantsEnabled(true); + target.setEnabled(true); + target.setSecret("secret"); + target.setServiceAccountsEnabled(true); + target.setFullScopeAllowed(false); + target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + new org.keycloak.services.managers.ClientManager(new org.keycloak.services.managers.RealmManager(session)).enableServiceAccount(target); + + target = realm.addClient("no-account"); + target.setClientId("no-account"); + target.setDirectAccessGrantsEnabled(true); + target.setEnabled(true); + target.setSecret("secret"); + target.setServiceAccountsEnabled(true); + target.setFullScopeAllowed(false); + target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + + UserModel user = session.users().addUser(realm, "user"); + user.setEnabled(true); + session.userCredentialManager().updateCredential(realm, user, UserCredentialModel.password("password")); + user.grantRole(exampleRole); + + } + + @Override + protected boolean isImportAfterEachMethod() { + return true; + } + + + @Test + public void testExchange() throws Exception { + testingClient.server().run(TokenExchangeTest::setupRealm); + + oauth.realm(TEST); + oauth.clientId("realm-exchanger"); + + OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password"); + String accessToken = response.getAccessToken(); + + response = oauth.doTokenExchange(TEST,accessToken, "target", "realm-exchanger", "secret"); + + String exchangedTokenString = response.getAccessToken(); + TokenVerifier verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class); + AccessToken exchangedToken = verifier.parse().getToken(); + Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); + Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + + + } +} From 6b991b850eff903bf32fd353b8c55a9c03799790 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 28 Jul 2017 16:20:23 -0400 Subject: [PATCH 2/6] change role name --- .../java/org/keycloak/OAuth2Constants.java | 2 +- .../oidc/endpoints/TokenEndpoint.java | 4 +-- .../testsuite/oauth/TokenExchangeTest.java | 25 ++----------------- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 70105f7074..6de35b8b67 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -101,7 +101,7 @@ public interface OAuth2Constants { String REFRESH_TOKEN_TYPE="urn:ietf:params:oauth:token-type:refresh_token"; String JWT_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt"; String ID_TOKEN_TYPE="urn:ietf:params:oauth:token-type:id_token"; - String TOKEN_EXCHANGEABLE ="token-exchangeable"; + String TOKEN_EXCHANGER ="token-exchanger"; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 69499175e7..cb5b5ae05e 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -602,8 +602,8 @@ public class TokenEndpoint { boolean allowed = false; UserModel serviceAccount = session.users().getServiceAccount(client); if (serviceAccount != null) { - RoleModel exchangeable = targetClient.getRole(OAuth2Constants.TOKEN_EXCHANGEABLE); - RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().getRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + RoleModel exchangeable = targetClient.getRole(OAuth2Constants.TOKEN_EXCHANGER); + RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().getRole(OAuth2Constants.TOKEN_EXCHANGER); allowed = (exchangeable != null && serviceAccount.hasRole(exchangeable)) || (realmExchangeable != null && serviceAccount.hasRole(realmExchangeable)); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java index 5a6e3bb211..ff82166f63 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenExchangeTest.java @@ -17,48 +17,27 @@ package org.keycloak.testsuite.oauth; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.DefaultHttpClient; import org.jboss.arquillian.container.test.api.Deployment; import org.jboss.shrinkwrap.api.spec.WebArchive; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; -import org.keycloak.admin.client.resource.RealmResource; -import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; -import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.AccessToken; -import org.keycloak.representations.RefreshToken; -import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; -import org.keycloak.testsuite.admin.FineGrainAdminUnitTest; import org.keycloak.testsuite.runonserver.RunOnServerDeployment; -import org.keycloak.testsuite.util.ClientBuilder; -import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; -import org.keycloak.testsuite.util.RealmBuilder; -import org.keycloak.testsuite.util.RealmManager; -import org.keycloak.testsuite.util.UserBuilder; -import org.keycloak.testsuite.util.UserManager; -import org.keycloak.util.JsonSerialization; import java.util.List; @@ -89,7 +68,7 @@ public class TokenExchangeTest extends AbstractKeycloakTest { public static void setupRealm(KeycloakSession session) { RealmModel realm = session.realms().getRealmByName(TEST); - RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().addRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().addRole(OAuth2Constants.TOKEN_EXCHANGER); RoleModel exampleRole = realm.addRole("example"); @@ -100,7 +79,7 @@ public class TokenExchangeTest extends AbstractKeycloakTest { target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); target.setFullScopeAllowed(false); target.addScopeMapping(exampleRole); - RoleModel targetExchangeable = target.addRole(OAuth2Constants.TOKEN_EXCHANGEABLE); + RoleModel targetExchangeable = target.addRole(OAuth2Constants.TOKEN_EXCHANGER); target = realm.addClient("realm-exchanger"); target.setClientId("realm-exchanger"); From 02f043d9a67c6d34da294af73f845cb81fc56f0e Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 28 Jul 2017 16:21:33 -0400 Subject: [PATCH 3/6] fix readme file --- adapters/oidc/cli-sso/README.md | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/adapters/oidc/cli-sso/README.md b/adapters/oidc/cli-sso/README.md index 5409ec630a..fb0fdbecc5 100755 --- a/adapters/oidc/cli-sso/README.md +++ b/adapters/oidc/cli-sso/README.md @@ -7,29 +7,3 @@ idea is that the Java app provided by this utility performs a login for a specif client, parses responses, and exports an access token as an environment variable that can be used by the command line utility you are accessing. -So, the idea is that you create a login script as follows: - - #!/bin/sh - export KC_TOKEN_RESPONSE=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` - export KC_ACCESS_TOKEN=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar token` - - -You pass in the parameters for connecting to Keycloak through Java system properties. The command `login` will perform the following steps: -1. Look to see if `KEYCLOAK_TOKEN_RESPONSE` is set. If so, it will check expiration information and perform a refresh token call if the token needs refreshing. -2. If `KEYCLOAK_TOKEN_RESPONSE` is not set, then it will use open a browser and perform a login to obtain an OAuth token response back. -3. The output to STDOUT will be the oauth accesss token response. This will be stored by the script in an environment variable. -4. The 2nd line of the script extracts the access token from the response json and stuffs it in an environment variable that will be used by the CLI application. - -So, the idea is that you wrap your CLI application within this script and extract the access token -from an environment variable. For example, if our CLI application is a C program named `mycli` the script would look like this: - - #!/bin/sh - export KC_TOKEN_RESPONSE=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -DKEYCLOAK_AUTH_SERVER=$KC_AUTH_SERVER -DKEYCLOAK_REALM=$KC_REALM -DKEYCLOAK_CLIENT=$KC_CLIENT -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar login` - export KC_ACCESS_TOKEN=`java -DKEYCLOAK_TOKEN_RESPONSE=$KC_TOKEN_RESPONSE -jar target/keycloak-cli-sso-3.3.0.CR1-SNAPSHOT.jar token` - mycli $@ - -You would invoke it as follows: - - $ . mycli.sh - -Using the `.` so that the environment variables get exported. From 486a0c952853416e617f4d63e978afd794883500 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 28 Jul 2017 16:25:32 -0400 Subject: [PATCH 4/6] remove restriction --- .../keycloak/protocol/oidc/endpoints/TokenEndpoint.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index cb5b5ae05e..07a6da56d5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -572,13 +572,6 @@ public class TokenEndpoint { throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.BAD_REQUEST); } - if (authResult.getToken().getAudience() == null || authResult.getToken().getAudience().length > 1 - || !client.getClientId().equals(authResult.getToken().getAudience()[0]) ) { - - event.error(Errors.INVALID_TOKEN); - throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Cannot exchange token from different client", Response.Status.BAD_REQUEST); - } - String audience = formParams.getFirst(OAuth2Constants.AUDIENCE); if (audience == null) { event.error(Errors.INVALID_REQUEST); From 8f542618f7cdf657a543ae5a51ed15148d549142 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Mon, 31 Jul 2017 10:36:04 -0400 Subject: [PATCH 5/6] KEYCLOAK-4748 --- .../oidc/endpoints/TokenEndpoint.java | 32 +++++++++++++++++-- .../resources/IdentityBrokerService.java | 12 ------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 07a6da56d5..5b70d9b196 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -595,10 +595,36 @@ public class TokenEndpoint { boolean allowed = false; UserModel serviceAccount = session.users().getServiceAccount(client); if (serviceAccount != null) { - RoleModel exchangeable = targetClient.getRole(OAuth2Constants.TOKEN_EXCHANGER); - RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().getRole(OAuth2Constants.TOKEN_EXCHANGER); - allowed = (exchangeable != null && serviceAccount.hasRole(exchangeable)) || (realmExchangeable != null && serviceAccount.hasRole(realmExchangeable)); + if (authResult.getToken().getAudience() == null) { + logger.debug("Client doesn't have service account"); + } + boolean tokenAllowed = false; + for (String aud : authResult.getToken().getAudience()) { + ClientModel audClient = realm.getClientByClientId(aud); + if (audClient == null) continue; + if (audClient.equals(client)) { + tokenAllowed = true; + break; + } + RoleModel audExchanger = audClient.getRole(OAuth2Constants.TOKEN_EXCHANGER); + if (audExchanger != null && serviceAccount.hasRole(audExchanger)) { + tokenAllowed = true; + break; + } + } + if (!tokenAllowed) { + logger.debug("Client does not have exchange rights for audience of token"); + } else { + RoleModel targetExchangable = targetClient.getRole(OAuth2Constants.TOKEN_EXCHANGER); + RoleModel realmExchangeable = AdminPermissions.management(session, realm).getRealmManagementClient().getRole(OAuth2Constants.TOKEN_EXCHANGER); + allowed = (targetExchangable != null && serviceAccount.hasRole(targetExchangable)) || (realmExchangeable != null && serviceAccount.hasRole(realmExchangeable)); + if (!allowed) { + logger.debug("Client does not have exchange rights for target audience"); + } + } + } else { + logger.debug("Client doesn't have service account"); } if (!allowed) { diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java index a3983a4ac6..7961163231 100755 --- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java +++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java @@ -221,18 +221,6 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal } - // only allow origins from client. Not sure we need this as I don't believe cookies can be - // sent if CORS preflight requests can't execute. - String origin = headers.getRequestHeaders().getFirst("Origin"); - if (origin != null) { - String redirectOrigin = UriUtils.getOrigin(redirectUri); - if (!redirectOrigin.equals(origin)) { - event.error(Errors.ILLEGAL_ORIGIN); - throw new ErrorPageException(session, Messages.INVALID_REQUEST); - - } - } - AuthenticationManager.AuthResult cookieResult = AuthenticationManager.authenticateIdentityCookie(session, realmModel, true); String errorParam = "link_error"; if (cookieResult == null) { From 8c93fdfb62261ed8175ce0ed708843b81bb14687 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Mon, 31 Jul 2017 13:01:23 -0400 Subject: [PATCH 6/6] fix test --- .../admin/FineGrainAdminUnitTest.java | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java index 8bfc9561f4..5e9a47322b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/FineGrainAdminUnitTest.java @@ -766,21 +766,26 @@ public class FineGrainAdminUnitTest extends AbstractKeycloakTest { ClientRepresentation newClient = new ClientRepresentation(); - newClient.setName("newClient"); - newClient.setClientId("newClient"); - newClient.setFullScopeAllowed(true); - newClient.setSecret("secret"); - newClient.setProtocol("openid-connect"); - newClient.setPublicClient(false); - newClient.setEnabled(true); - Response response = realmClient.realm("anotherRealm").clients().create(newClient); - Assert.assertEquals(403, response.getStatus()); + try { + newClient.setName("newClient"); + newClient.setClientId("newClient"); + newClient.setFullScopeAllowed(true); + newClient.setSecret("secret"); + newClient.setProtocol("openid-connect"); + newClient.setPublicClient(false); + newClient.setEnabled(true); + Response response = realmClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(403, response.getStatus()); - realmClient.close(); - realmClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), - "master", "admin", "admin", "fullScopedClient", "618268aa-51e6-4e64-93c4-3c0bc65b8171"); - response = realmClient.realm("anotherRealm").clients().create(newClient); - Assert.assertEquals(201, response.getStatus()); + realmClient.close(); + realmClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), + "master", "admin", "admin", "fullScopedClient", "618268aa-51e6-4e64-93c4-3c0bc65b8171"); + response = realmClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(201, response.getStatus()); + } finally { + adminClient.realm("anotherRealm").remove(); + + } } @@ -804,17 +809,22 @@ public class FineGrainAdminUnitTest extends AbstractKeycloakTest { newRealm.setEnabled(true); adminClient.realms().create(newRealm); - ClientRepresentation newClient = new ClientRepresentation(); + try { + ClientRepresentation newClient = new ClientRepresentation(); - newClient.setName("newClient"); - newClient.setClientId("newClient"); - newClient.setFullScopeAllowed(true); - newClient.setSecret("secret"); - newClient.setProtocol("openid-connect"); - newClient.setPublicClient(false); - newClient.setEnabled(true); - Response response = adminClient.realm("anotherRealm").clients().create(newClient); - Assert.assertEquals(201, response.getStatus()); + newClient.setName("newClient"); + newClient.setClientId("newClient"); + newClient.setFullScopeAllowed(true); + newClient.setSecret("secret"); + newClient.setProtocol("openid-connect"); + newClient.setPublicClient(false); + newClient.setEnabled(true); + Response response = adminClient.realm("anotherRealm").clients().create(newClient); + Assert.assertEquals(201, response.getStatus()); + } finally { + adminClient.realm("anotherRealm").remove(); + + } }