diff --git a/core/src/main/java/org/keycloak/AbstractOAuthClient.java b/core/src/main/java/org/keycloak/AbstractOAuthClient.java index ccc5393586..e3989e178e 100755 --- a/core/src/main/java/org/keycloak/AbstractOAuthClient.java +++ b/core/src/main/java/org/keycloak/AbstractOAuthClient.java @@ -18,6 +18,7 @@ public class AbstractOAuthClient { protected KeyStore truststore; protected String authUrl; protected String codeUrl; + protected String refreshUrl; protected String scope; protected String stateCookieName = OAUTH_TOKEN_REQUEST_STATE; protected String stateCookiePath; @@ -70,6 +71,14 @@ public class AbstractOAuthClient { this.codeUrl = codeUrl; } + public String getRefreshUrl() { + return refreshUrl; + } + + public void setRefreshUrl(String refreshUrl) { + this.refreshUrl = refreshUrl; + } + public String getScope() { return scope; } diff --git a/docbook/reference/en/en-US/master.xml b/docbook/reference/en/en-US/master.xml index 23d2827f99..0537e1b02d 100755 --- a/docbook/reference/en/en-US/master.xml +++ b/docbook/reference/en/en-US/master.xml @@ -7,6 +7,7 @@ + @@ -63,6 +64,7 @@ &AdapterConfig; &JBossAdapter; + &JavascriptAdapter; diff --git a/docbook/reference/en/en-US/modules/MigrationFromOlderVersions.xml b/docbook/reference/en/en-US/modules/MigrationFromOlderVersions.xml index 8830a7f434..bfc96eb7ac 100755 --- a/docbook/reference/en/en-US/modules/MigrationFromOlderVersions.xml +++ b/docbook/reference/en/en-US/modules/MigrationFromOlderVersions.xml @@ -7,6 +7,14 @@ SkeletonKeyToken, SkeletonKeyScope, SkeletonKeyPrincipal, and SkeletonKeySession have been renamed to: AccessToken, AccessScope, KeycloakPrincipal, and KeycloakAuthenticatedSession respectively. + + ServleOAuthClient.getBearerToken() method signature has changed. It now returns an AccessTokenResponse + so that you can obtain a refresh token too. + + + Adapters now check the access token expiration with every request. If the token is expired, they will + attempt to invoke a refresh on the auth server using a saved refresh token. + diff --git a/docbook/reference/en/en-US/modules/javascript-adapter.xml b/docbook/reference/en/en-US/modules/javascript-adapter.xml new file mode 100755 index 0000000000..45c41d1add --- /dev/null +++ b/docbook/reference/en/en-US/modules/javascript-adapter.xml @@ -0,0 +1,94 @@ +
+ Pure Client Javascript Adapter + + The Keycloak Server comes with a Javascript library you can use to secure pure HTML/Javascript applications. It + works in the same way as other application adapters accept that your browser is driving the OAuth redirect protocol + rather than the server. + + + The + disadvantage of using this approach is that you end up having a non-confidential, public client. This can be mitigated + by registering valid redirect URLs. You are still vulnerable if somebody hijacks the IP/DNS name of your pure + HTML/Javascript application though. + + + To use this adapter, you first must load and initialize the keycloak javascript library into your application. + + Customer View Page + + + +]]> + + + The above code will initialize the adapter and redirect you to your realm's login screen. You must fill in the + appropriate clientId, clientSecret, and realm options + based on how you created your application in your realm through the admin console. The init() + method can also take a success and error callback function as parameters. + + + After you login, your application will be able to make REST calls using bearer token authentication. Here's + an example pulled from the customer-portal-js example that comes with the distribution. + + var loadData = function () { + document.getElementById('username').innerText = keycloak.username; + + var url = 'http://localhost:8080/database/customers'; + + var req = new XMLHttpRequest(); + req.open('GET', url, true); + req.setRequestHeader('Accept', 'application/json'); + req.setRequestHeader('Authorization', 'Bearer ' + keycloak.token); + + req.onreadystatechange = function () { + if (req.readyState == 4) { + if (req.status == 200) { + var users = JSON.parse(req.responseText); + var html = ''; + for (var i = 0; i < users.length; i++) { + html += '

' + users[i] + '

'; + } + document.getElementById('customers').innerHTML = html; + console.log('finished loading data'); + } + } + } + + req.send(); + }; + + var loadFailure = function () { + document.getElementById('customers').innerHTML = 'Failed to load data. Check console log'; + + }; + + var reloadData = function () { + keycloak.onValidAccessToken(loadData, loadFailure); + } + + + +]]>
+ +
+ + The loadData() method builds an HTTP request setting the Authorization + header to a bearer token. The keycloak.token points to the access token the browser obtained + when it logged you in. The loadFailure() method is invoked on a failure. The reloadData() + function calls keycloak.onValidAccessToken() passing in the loadData() and + loadFailure() callbacks. The keycloak.onValidAcessToken() method checks to + see if the access token hasn't expired. If it hasn't, and your oauth login returned a refresh token, this method + will refresh the access token. Finally, if successful, it will invoke the success callback, which in this case + is the loadData() method. + +
\ No newline at end of file diff --git a/examples/demo-template/README.md b/examples/demo-template/README.md index 2d36e7ccb0..5d5fba74de 100755 --- a/examples/demo-template/README.md +++ b/examples/demo-template/README.md @@ -10,6 +10,7 @@ The following examples requires Wildfly 8.0.0, JBoss EAP 6.x, or JBoss AS 7.1.1. There are multiple WAR projects. These will all run on the same WildFly instance, but pretend each one is running on a different machine on the network or Internet. * **customer-app** A WAR application that does remote login using OAuth2 browser redirects with the auth server +* **customer-app-js** A pure HTML/Javascript application that does remote login using OAuth2 browser redirects with the auth server * **product-app** A WAR application that does remote login using OAuth2 browser redirects with the auth server * **database-service** JAX-RS services authenticated by bearer tokens only. The customer and product app invoke on it to get data * **third-party** Simple WAR that obtain a bearer token using OAuth2 using browser redirects to the auth-server. @@ -146,6 +147,9 @@ are still happening, but the auth-server knows you are already logged in so the If you click on the logout link of either of the product or customer app, you'll be logged out of all the applications. +Ff you click on [http://localhost:8080/customer-portal-js](http://localhost:8080/customer-portal-js) you can invoke +on the pure HTML/Javascript application. + Step 6: Traditional OAuth2 Example ---------------------------------- The customer and product apps are logins. The third-party app is the traditional OAuth2 usecase of a client wanting diff --git a/examples/demo-template/customer-app-js/src/main/webapp/customers/view.html b/examples/demo-template/customer-app-js/src/main/webapp/customers/view.html index 27227024ef..36ef79f38f 100755 --- a/examples/demo-template/customer-app-js/src/main/webapp/customers/view.html +++ b/examples/demo-template/customer-app-js/src/main/webapp/customers/view.html @@ -12,13 +12,13 @@ User made this request.

+ \ No newline at end of file diff --git a/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/CustomerService.java b/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/CustomerService.java index 535e3fe1c3..b1cf249e21 100755 --- a/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/CustomerService.java +++ b/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/CustomerService.java @@ -1,5 +1,7 @@ package org.keycloak.example.oauth; +import org.jboss.resteasy.annotations.cache.NoCache; + import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; @@ -14,6 +16,7 @@ import java.util.List; public class CustomerService { @GET @Produces("application/json") + @NoCache public List getCustomers() { ArrayList rtn = new ArrayList(); rtn.add("Bill Burke"); diff --git a/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/ProductService.java b/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/ProductService.java index fb5f620fa2..10fd5d765f 100755 --- a/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/ProductService.java +++ b/examples/demo-template/database-service/src/main/java/org/keycloak/example/oauth/ProductService.java @@ -1,5 +1,7 @@ package org.keycloak.example.oauth; +import org.jboss.resteasy.annotations.cache.NoCache; + import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; @@ -14,6 +16,7 @@ import java.util.List; public class ProductService { @GET @Produces("application/json") + @NoCache public List getProducts() { ArrayList rtn = new ArrayList(); rtn.add("iphone"); diff --git a/examples/demo-template/pom.xml b/examples/demo-template/pom.xml index ae1bb62584..8185fb422a 100755 --- a/examples/demo-template/pom.xml +++ b/examples/demo-template/pom.xml @@ -36,6 +36,7 @@ customer-app + customer-app-js product-app database-service third-party diff --git a/examples/demo-template/third-party-cdi/src/main/java/org/keycloak/example/oauth/RefreshTokenFilter.java b/examples/demo-template/third-party-cdi/src/main/java/org/keycloak/example/oauth/RefreshTokenFilter.java index 7467959298..a1a71c2464 100755 --- a/examples/demo-template/third-party-cdi/src/main/java/org/keycloak/example/oauth/RefreshTokenFilter.java +++ b/examples/demo-template/third-party-cdi/src/main/java/org/keycloak/example/oauth/RefreshTokenFilter.java @@ -43,7 +43,7 @@ public class RefreshTokenFilter implements Filter { if (reqParams.containsKey("code")) { try { - String accessToken = oauthClient.getBearerToken(request); + String accessToken = oauthClient.getBearerToken(request).getToken(); userData.setAccessToken(accessToken); } catch (TokenGrantRequest.HttpFailure e) { throw new ServletException(e); diff --git a/examples/demo-template/third-party/src/main/java/org/keycloak/example/oauth/ProductDatabaseClient.java b/examples/demo-template/third-party/src/main/java/org/keycloak/example/oauth/ProductDatabaseClient.java index be32a3918b..9ab51b2279 100755 --- a/examples/demo-template/third-party/src/main/java/org/keycloak/example/oauth/ProductDatabaseClient.java +++ b/examples/demo-template/third-party/src/main/java/org/keycloak/example/oauth/ProductDatabaseClient.java @@ -59,7 +59,7 @@ public class ProductDatabaseClient { ServletOAuthClient oAuthClient = (ServletOAuthClient) request.getServletContext().getAttribute(ServletOAuthClient.class.getName()); String token = null; try { - token = oAuthClient.getBearerToken(request); + token = oAuthClient.getBearerToken(request).getToken(); } catch (IOException e) { throw new RuntimeException(e); } catch (TokenGrantRequest.HttpFailure failure) { diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/config/OAuthClientConfigLoader.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/config/OAuthClientConfigLoader.java index 3e53fee27f..23fb072069 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/config/OAuthClientConfigLoader.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/config/OAuthClientConfigLoader.java @@ -35,8 +35,10 @@ public abstract class OAuthClientConfigLoader extends RealmConfigurationLoader { KeycloakUriBuilder serverBuilder = KeycloakUriBuilder.fromUri(adapterConfig.getAuthServerUrl()); String authUrl = serverBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_LOGIN_PATH).build(adapterConfig.getRealm()).toString(); String tokenUrl = serverBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_ACCESS_CODE_PATH).build(adapterConfig.getRealm()).toString(); + String refreshUrl = serverBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_REFRESH_PATH).build(adapterConfig.getRealm()).toString(); oauthClient.setAuthUrl(authUrl); oauthClient.setCodeUrl(tokenUrl); + oauthClient.setRefreshUrl(refreshUrl); oauthClient.setTruststore(truststore); if (adapterConfig.getScope() != null) { String scope = encodeScope(adapterConfig.getScope()); diff --git a/integration/js/src/main/resources/META-INF/resources/js/keycloak.js b/integration/js/src/main/resources/META-INF/resources/js/keycloak.js index 077e852f1a..5d4bf8c7b8 100755 --- a/integration/js/src/main/resources/META-INF/resources/js/keycloak.js +++ b/integration/js/src/main/resources/META-INF/resources/js/keycloak.js @@ -101,6 +101,55 @@ var Keycloak = function (options) { req.send(); } + /** + * checks to make sure token is valid. If it is, it calls successCallback with no parameters. + * If it isn't valid, it tries to refresh the access token. On successful refresh, it calls successCallback. + * + * @param successCallback + * @param errorCallback + */ + this.onValidAccessToken = function(successCallback, errorCallback) { + if (!this.tokenParsed) { + console.log('no token'); + errorCallback(); + return; + } + var currTime = new Date().getTime() / 1000; + if (currTime > this.tokenParsed['exp']) { + if (!this.refreshToken) { + console.log('no refresh token'); + errorCallback(); + return; + } + console.log('calling refresh'); + var params = 'grant_type=refresh_token&' + 'refresh_token=' + this.refreshToken; + var url = getRealmUrl() + '/tokens/refresh'; + + var req = new XMLHttpRequest(); + req.open('POST', url, true, options.clientId, options.clientSecret); + req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + + req.onreadystatechange = function () { + if (req.readyState == 4) { + if (req.status == 200) { + console.log('Refresh Success'); + var tokenResponse = JSON.parse(req.responseText); + this.refreshToken = tokenResponse['refresh_token']; + setToken(tokenResponse['access_token'], successCallback); + } else { + console.log('error on refresh HTTP invoke: ' + req.status); + errorCallback && errorCallback({ authenticated: false, status: req.status, statusText: req.statusText }); + } + } + }; + req.send(params); + } else { + console.log('Token is still valid'); + successCallback(); + } + + } + function getRealmUrl() { return options.url + '/auth/rest/realms/' + encodeURIComponent(options.realm); } @@ -121,7 +170,9 @@ var Keycloak = function (options) { req.onreadystatechange = function () { if (req.readyState == 4) { if (req.status == 200) { - setToken(JSON.parse(req.responseText)['access_token'], successCallback); + var tokenResponse = JSON.parse(req.responseText); + instance.refreshToken = tokenResponse['refresh_token']; + setToken(tokenResponse['access_token'], successCallback); } else { errorCallback && errorCallback({ authenticated: false, status: req.status, statusText: req.statusText }); } diff --git a/integration/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java b/integration/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java index 5d17c0a654..44c0b9c25f 100755 --- a/integration/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java +++ b/integration/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java @@ -4,6 +4,7 @@ import org.apache.http.client.HttpClient; import org.keycloak.AbstractOAuthClient; import org.keycloak.adapters.HttpClientBuilder; import org.keycloak.adapters.TokenGrantRequest; +import org.keycloak.representations.AccessTokenResponse; import org.keycloak.util.KeycloakUriBuilder; import javax.servlet.http.Cookie; @@ -45,8 +46,8 @@ public class ServletOAuthClient extends AbstractOAuthClient { this.client = client; } - public String resolveBearerToken(String redirectUri, String code) throws IOException, TokenGrantRequest.HttpFailure { - return TokenGrantRequest.invokeAccessCodeToToken(client, code, codeUrl, redirectUri, clientId, credentials).getToken(); + public AccessTokenResponse resolveBearerToken(String redirectUri, String code) throws IOException, TokenGrantRequest.HttpFailure { + return TokenGrantRequest.invokeAccessCodeToToken(client, code, codeUrl, redirectUri, clientId, credentials); } /** @@ -133,7 +134,7 @@ public class ServletOAuthClient extends AbstractOAuthClient { * @throws IOException * @throws org.keycloak.adapters.TokenGrantRequest.HttpFailure */ - public String getBearerToken(HttpServletRequest request) throws IOException, TokenGrantRequest.HttpFailure { + public AccessTokenResponse getBearerToken(HttpServletRequest request) throws IOException, TokenGrantRequest.HttpFailure { String error = request.getParameter("error"); if (error != null) throw new IOException("OAuth error: " + error); String redirectUri = request.getRequestURL().append("?").append(request.getQueryString()).toString(); @@ -151,5 +152,9 @@ public class ServletOAuthClient extends AbstractOAuthClient { return resolveBearerToken(redirectUri, code); } + public AccessTokenResponse refreshToken(String refreshToken) throws IOException, TokenGrantRequest.HttpFailure { + return TokenGrantRequest.invokeRefresh(client, refreshToken, refreshUrl, clientId, credentials); + } + } diff --git a/services/src/main/java/org/keycloak/services/resources/TokenService.java b/services/src/main/java/org/keycloak/services/resources/TokenService.java index 24d80907db..44ac6e2821 100755 --- a/services/src/main/java/org/keycloak/services/resources/TokenService.java +++ b/services/src/main/java/org/keycloak/services/resources/TokenService.java @@ -169,6 +169,7 @@ public class TokenService { @Produces(MediaType.APPLICATION_JSON) public Response refreshAccessToken(final @HeaderParam(HttpHeaders.AUTHORIZATION) String authorizationHeader, final MultivaluedMap form) { + logger.info("--> refreshAccessToken"); if (!checkSsl()) { throw new NotAcceptableException("HTTPS required"); }