Merge pull request #238 from patriot1burke/master

javascript refresh token
This commit is contained in:
Bill Burke 2014-02-24 19:59:22 -05:00
commit a3e437f82a
15 changed files with 208 additions and 11 deletions

View file

@ -18,6 +18,7 @@ public class AbstractOAuthClient {
protected KeyStore truststore; protected KeyStore truststore;
protected String authUrl; protected String authUrl;
protected String codeUrl; protected String codeUrl;
protected String refreshUrl;
protected String scope; protected String scope;
protected String stateCookieName = OAUTH_TOKEN_REQUEST_STATE; protected String stateCookieName = OAUTH_TOKEN_REQUEST_STATE;
protected String stateCookiePath; protected String stateCookiePath;
@ -70,6 +71,14 @@ public class AbstractOAuthClient {
this.codeUrl = codeUrl; this.codeUrl = codeUrl;
} }
public String getRefreshUrl() {
return refreshUrl;
}
public void setRefreshUrl(String refreshUrl) {
this.refreshUrl = refreshUrl;
}
public String getScope() { public String getScope() {
return scope; return scope;
} }

View file

@ -7,6 +7,7 @@
<!ENTITY OpenShift SYSTEM "modules/openshift.xml"> <!ENTITY OpenShift SYSTEM "modules/openshift.xml">
<!ENTITY AdapterConfig SYSTEM "modules/adapter-config.xml"> <!ENTITY AdapterConfig SYSTEM "modules/adapter-config.xml">
<!ENTITY JBossAdapter SYSTEM "modules/jboss-adapter.xml"> <!ENTITY JBossAdapter SYSTEM "modules/jboss-adapter.xml">
<!ENTITY JavascriptAdapter SYSTEM "modules/javascript-adapter.xml">
<!ENTITY SocialConfig SYSTEM "modules/social-config.xml"> <!ENTITY SocialConfig SYSTEM "modules/social-config.xml">
<!ENTITY SocialFacebook SYSTEM "modules/social-facebook.xml"> <!ENTITY SocialFacebook SYSTEM "modules/social-facebook.xml">
<!ENTITY SocialGitHub SYSTEM "modules/social-github.xml"> <!ENTITY SocialGitHub SYSTEM "modules/social-github.xml">
@ -63,6 +64,7 @@
</para> </para>
&AdapterConfig; &AdapterConfig;
&JBossAdapter; &JBossAdapter;
&JavascriptAdapter;
</chapter> </chapter>
<chapter> <chapter>

View file

@ -7,6 +7,14 @@
SkeletonKeyToken, SkeletonKeyScope, SkeletonKeyPrincipal, and SkeletonKeySession have been renamed to: SkeletonKeyToken, SkeletonKeyScope, SkeletonKeyPrincipal, and SkeletonKeySession have been renamed to:
AccessToken, AccessScope, KeycloakPrincipal, and KeycloakAuthenticatedSession respectively. AccessToken, AccessScope, KeycloakPrincipal, and KeycloakAuthenticatedSession respectively.
</listitem> </listitem>
<listitem>
ServleOAuthClient.getBearerToken() method signature has changed. It now returns an AccessTokenResponse
so that you can obtain a refresh token too.
</listitem>
<listitem>
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.
</listitem>
</itemizedlist> </itemizedlist>
</sect1> </sect1>
<sect1> <sect1>

View file

@ -0,0 +1,94 @@
<section>
<title>Pure Client Javascript Adapter</title>
<para>
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.
</para>
<para>
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.
</para>
<para>
To use this adapter, you first must load and initialize the keycloak javascript library into your application.
<programlisting><![CDATA[
<head>
<title>Customer View Page</title>
<script src="/auth/js/keycloak.js"></script>
<script>
var keycloak = Keycloak({
clientId: 'application-name',
clientSecret: '1234234-234234-234234-234234',
realm: 'demo',
onload: 'login-required'
});
keycloak.init();
</script>
</head>
]]></programlisting>
</para>
<para>
The above code will initialize the adapter and redirect you to your realm's login screen. You must fill in the
appropriate <literal>clientId</literal>, <literal>clientSecret</literal>, and <literal>realm</literal> options
based on how you created your application in your realm through the admin console. The <literal>init()</literal>
method can also take a success and error callback function as parameters.
</para>
<para>
After you login, your application will be able to make REST calls using bearer token authentication. Here's
an example pulled from the <literal>customer-portal-js</literal> example that comes with the distribution.
<programlisting><![CDATA[
<script>
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 += '<p>' + users[i] + '</p>';
}
document.getElementById('customers').innerHTML = html;
console.log('finished loading data');
}
}
}
req.send();
};
var loadFailure = function () {
document.getElementById('customers').innerHTML = '<b>Failed to load data. Check console log</b>';
};
var reloadData = function () {
keycloak.onValidAccessToken(loadData, loadFailure);
}
</script>
<button onclick="reloadData()">Submit</button>
]]></programlisting>
</para>
<para>
The <literal>loadData()</literal> method builds an HTTP request setting the <literal>Authorization</literal>
header to a bearer token. The <literal>keycloak.token</literal> points to the access token the browser obtained
when it logged you in. The <literal>loadFailure()</literal> method is invoked on a failure. The <literal>reloadData()</literal>
function calls <literal>keycloak.onValidAccessToken()</literal> passing in the <literal>loadData()</literal> and
<literal>loadFailure()</literal> callbacks. The <literal>keycloak.onValidAcessToken()</literal> 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 <literal>loadData()</literal> method.
</para>
</section>

View file

@ -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 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. 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** 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 * **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 * **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. * **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. 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 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 The customer and product apps are logins. The third-party app is the traditional OAuth2 usecase of a client wanting

View file

@ -12,13 +12,13 @@ User <b id="username"></b> made this request.
<script> <script>
var keycloak = Keycloak({ var keycloak = Keycloak({
clientId: '<INSERT CLIENT ID>', clientId: 'customer-portal',
clientSecret: '<INSERT SECRET>', clientSecret: 'password',
realm: '<INSERT REALM NAME>', realm: 'demo',
onload: 'login-required' onload: 'login-required'
}); });
keycloak.init(function() { var loadData = function () {
document.getElementById('username').innerText = keycloak.username; document.getElementById('username').innerText = keycloak.username;
var url = 'http://localhost:8080/database/customers'; var url = 'http://localhost:8080/database/customers';
@ -37,15 +37,29 @@ User <b id="username"></b> made this request.
html += '<p>' + users[i] + '</p>'; html += '<p>' + users[i] + '</p>';
} }
document.getElementById('customers').innerHTML = html; document.getElementById('customers').innerHTML = html;
console.log('finished loading data');
} }
} }
} }
req.send(); req.send();
}); };
var loadFailure = function () {
document.getElementById('customers').innerHTML = '<b>Failed to load data. Check console log</b>';
};
var reloadData = function () {
keycloak.onValidAccessToken(loadData, loadFailure);
}
keycloak.init(loadData);
</script> </script>
<br><br> <br><br>
<button onclick="reloadData()">Reload data</button>
</body> </body>
</html> </html>

View file

@ -1,5 +1,7 @@
package org.keycloak.example.oauth; package org.keycloak.example.oauth;
import org.jboss.resteasy.annotations.cache.NoCache;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
@ -14,6 +16,7 @@ import java.util.List;
public class CustomerService { public class CustomerService {
@GET @GET
@Produces("application/json") @Produces("application/json")
@NoCache
public List<String> getCustomers() { public List<String> getCustomers() {
ArrayList<String> rtn = new ArrayList<String>(); ArrayList<String> rtn = new ArrayList<String>();
rtn.add("Bill Burke"); rtn.add("Bill Burke");

View file

@ -1,5 +1,7 @@
package org.keycloak.example.oauth; package org.keycloak.example.oauth;
import org.jboss.resteasy.annotations.cache.NoCache;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
@ -14,6 +16,7 @@ import java.util.List;
public class ProductService { public class ProductService {
@GET @GET
@Produces("application/json") @Produces("application/json")
@NoCache
public List<String> getProducts() { public List<String> getProducts() {
ArrayList<String> rtn = new ArrayList<String>(); ArrayList<String> rtn = new ArrayList<String>();
rtn.add("iphone"); rtn.add("iphone");

View file

@ -36,6 +36,7 @@
<modules> <modules>
<!-- <module>server</module> --> <!-- <module>server</module> -->
<module>customer-app</module> <module>customer-app</module>
<module>customer-app-js</module>
<module>product-app</module> <module>product-app</module>
<module>database-service</module> <module>database-service</module>
<module>third-party</module> <module>third-party</module>

View file

@ -43,7 +43,7 @@ public class RefreshTokenFilter implements Filter {
if (reqParams.containsKey("code")) { if (reqParams.containsKey("code")) {
try { try {
String accessToken = oauthClient.getBearerToken(request); String accessToken = oauthClient.getBearerToken(request).getToken();
userData.setAccessToken(accessToken); userData.setAccessToken(accessToken);
} catch (TokenGrantRequest.HttpFailure e) { } catch (TokenGrantRequest.HttpFailure e) {
throw new ServletException(e); throw new ServletException(e);

View file

@ -59,7 +59,7 @@ public class ProductDatabaseClient {
ServletOAuthClient oAuthClient = (ServletOAuthClient) request.getServletContext().getAttribute(ServletOAuthClient.class.getName()); ServletOAuthClient oAuthClient = (ServletOAuthClient) request.getServletContext().getAttribute(ServletOAuthClient.class.getName());
String token = null; String token = null;
try { try {
token = oAuthClient.getBearerToken(request); token = oAuthClient.getBearerToken(request).getToken();
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} catch (TokenGrantRequest.HttpFailure failure) { } catch (TokenGrantRequest.HttpFailure failure) {

View file

@ -35,8 +35,10 @@ public abstract class OAuthClientConfigLoader extends RealmConfigurationLoader {
KeycloakUriBuilder serverBuilder = KeycloakUriBuilder.fromUri(adapterConfig.getAuthServerUrl()); KeycloakUriBuilder serverBuilder = KeycloakUriBuilder.fromUri(adapterConfig.getAuthServerUrl());
String authUrl = serverBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_LOGIN_PATH).build(adapterConfig.getRealm()).toString(); 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 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.setAuthUrl(authUrl);
oauthClient.setCodeUrl(tokenUrl); oauthClient.setCodeUrl(tokenUrl);
oauthClient.setRefreshUrl(refreshUrl);
oauthClient.setTruststore(truststore); oauthClient.setTruststore(truststore);
if (adapterConfig.getScope() != null) { if (adapterConfig.getScope() != null) {
String scope = encodeScope(adapterConfig.getScope()); String scope = encodeScope(adapterConfig.getScope());

View file

@ -101,6 +101,55 @@ var Keycloak = function (options) {
req.send(); 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() { function getRealmUrl() {
return options.url + '/auth/rest/realms/' + encodeURIComponent(options.realm); return options.url + '/auth/rest/realms/' + encodeURIComponent(options.realm);
} }
@ -121,7 +170,9 @@ var Keycloak = function (options) {
req.onreadystatechange = function () { req.onreadystatechange = function () {
if (req.readyState == 4) { if (req.readyState == 4) {
if (req.status == 200) { 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 { } else {
errorCallback && errorCallback({ authenticated: false, status: req.status, statusText: req.statusText }); errorCallback && errorCallback({ authenticated: false, status: req.status, statusText: req.statusText });
} }

View file

@ -4,6 +4,7 @@ import org.apache.http.client.HttpClient;
import org.keycloak.AbstractOAuthClient; import org.keycloak.AbstractOAuthClient;
import org.keycloak.adapters.HttpClientBuilder; import org.keycloak.adapters.HttpClientBuilder;
import org.keycloak.adapters.TokenGrantRequest; import org.keycloak.adapters.TokenGrantRequest;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.util.KeycloakUriBuilder; import org.keycloak.util.KeycloakUriBuilder;
import javax.servlet.http.Cookie; import javax.servlet.http.Cookie;
@ -45,8 +46,8 @@ public class ServletOAuthClient extends AbstractOAuthClient {
this.client = client; this.client = client;
} }
public String resolveBearerToken(String redirectUri, String code) throws IOException, TokenGrantRequest.HttpFailure { public AccessTokenResponse resolveBearerToken(String redirectUri, String code) throws IOException, TokenGrantRequest.HttpFailure {
return TokenGrantRequest.invokeAccessCodeToToken(client, code, codeUrl, redirectUri, clientId, credentials).getToken(); return TokenGrantRequest.invokeAccessCodeToToken(client, code, codeUrl, redirectUri, clientId, credentials);
} }
/** /**
@ -133,7 +134,7 @@ public class ServletOAuthClient extends AbstractOAuthClient {
* @throws IOException * @throws IOException
* @throws org.keycloak.adapters.TokenGrantRequest.HttpFailure * @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"); String error = request.getParameter("error");
if (error != null) throw new IOException("OAuth error: " + error); if (error != null) throw new IOException("OAuth error: " + error);
String redirectUri = request.getRequestURL().append("?").append(request.getQueryString()).toString(); String redirectUri = request.getRequestURL().append("?").append(request.getQueryString()).toString();
@ -151,5 +152,9 @@ public class ServletOAuthClient extends AbstractOAuthClient {
return resolveBearerToken(redirectUri, code); return resolveBearerToken(redirectUri, code);
} }
public AccessTokenResponse refreshToken(String refreshToken) throws IOException, TokenGrantRequest.HttpFailure {
return TokenGrantRequest.invokeRefresh(client, refreshToken, refreshUrl, clientId, credentials);
}
} }

View file

@ -169,6 +169,7 @@ public class TokenService {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Response refreshAccessToken(final @HeaderParam(HttpHeaders.AUTHORIZATION) String authorizationHeader, public Response refreshAccessToken(final @HeaderParam(HttpHeaders.AUTHORIZATION) String authorizationHeader,
final MultivaluedMap<String, String> form) { final MultivaluedMap<String, String> form) {
logger.info("--> refreshAccessToken");
if (!checkSsl()) { if (!checkSsl()) {
throw new NotAcceptableException("HTTPS required"); throw new NotAcceptableException("HTTPS required");
} }