/* * 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.servlet; import org.keycloak.KeycloakSecurityContext; import org.keycloak.OAuth2Constants; import org.keycloak.adapters.AdapterDeploymentContext; import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.OIDCHttpFacade; import org.keycloak.adapters.ServerRequest; import org.keycloak.adapters.spi.AuthenticationError; import org.keycloak.adapters.spi.LogoutError; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.util.TokenUtil; import javax.security.cert.X509Certificate; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.util.List; import org.jboss.logging.Logger; import org.keycloak.common.util.Base64Url; import java.security.MessageDigest; import java.security.SecureRandom; /** * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @version $Revision: 1 $ */ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient { // https://tools.ietf.org/html/rfc7636#section-4 private String codeVerifier; private String codeChallenge; private String codeChallengeMethod = OAuth2Constants.PKCE_METHOD_S256; private static Logger logger = Logger.getLogger(ServletOAuthClient.class); public static String generateSecret() { return generateSecret(32); } public static String generateSecret(int bytes) { byte[] buf = new byte[bytes]; new SecureRandom().nextBytes(buf); return Base64Url.encode(buf); } private void setCodeVerifier() { codeVerifier = generateSecret(); logger.debugf("Generated codeVerifier = %s", codeVerifier); return; } private void setCodeChallenge() { try { if (codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(codeVerifier.getBytes()); StringBuilder sb = new StringBuilder(); for (byte b : md.digest()) { String hex = String.format("%02x", b); sb.append(hex); } codeChallenge = Base64Url.encode(sb.toString().getBytes()); } else { codeChallenge = Base64Url.encode(codeVerifier.getBytes()); } logger.debugf("Encode codeChallenge = %s, codeChallengeMethod = %s", codeChallenge, codeChallengeMethod); } catch (Exception e) { logger.info("PKCE client side unknown hash algorithm"); codeChallenge = Base64Url.encode(codeVerifier.getBytes()); } } /** * closes client */ public void stop() { getDeployment().getClient().getConnectionManager().shutdown(); } private AccessTokenResponse resolveBearerToken(HttpServletRequest request, String redirectUri, String code) throws IOException, ServerRequest.HttpFailure { // Don't send sessionId in oauth clients for now KeycloakDeployment resolvedDeployment = resolveDeployment(getDeployment(), request); // https://tools.ietf.org/html/rfc7636#section-4 if (codeVerifier != null) { logger.debugf("Before sending Token Request, codeVerifier = %s", codeVerifier); return ServerRequest.invokeAccessCodeToToken(resolvedDeployment, code, redirectUri, null, codeVerifier); } else { logger.debug("Before sending Token Request without codeVerifier"); return ServerRequest.invokeAccessCodeToToken(resolvedDeployment, code, redirectUri, null); } } /** * Start the process of obtaining an access token by redirecting the browser * to the authentication server * * @param relativePath path relative to context root you want auth server to redirect back to * @param request * @param response * @throws IOException */ public void redirectRelative(String relativePath, HttpServletRequest request, HttpServletResponse response) throws IOException { KeycloakUriBuilder builder = KeycloakUriBuilder.fromUri(request.getRequestURL().toString()) .replacePath(request.getContextPath()) .replaceQuery(null) .path(relativePath); String redirect = builder.toTemplate(); redirect(redirect, request, response); } /** * Start the process of obtaining an access token by redirecting the browser * to the authentication server * * @param redirectUri full URI you want auth server to redirect back to * @param request * @param response * @throws IOException */ public void redirect(String redirectUri, HttpServletRequest request, HttpServletResponse response) throws IOException { String state = getStateCode(); KeycloakDeployment resolvedDeployment = resolveDeployment(getDeployment(), request); String authUrl = resolvedDeployment.getAuthUrl().clone().build().toString(); String scopeParam = TokenUtil.attachOIDCScope(scope); // https://tools.ietf.org/html/rfc7636#section-4 if (resolvedDeployment.isPkce()) { setCodeVerifier(); setCodeChallenge(); } KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(authUrl) .queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE) .queryParam(OAuth2Constants.CLIENT_ID, getClientId()) .queryParam(OAuth2Constants.REDIRECT_URI, redirectUri) .queryParam(OAuth2Constants.STATE, state) .queryParam(OAuth2Constants.SCOPE, scopeParam); URI url = uriBuilder.build(); String stateCookiePath = this.stateCookiePath; if (stateCookiePath == null) stateCookiePath = request.getContextPath(); if (stateCookiePath.equals("")) stateCookiePath = "/"; Cookie cookie = new Cookie(stateCookieName, state); cookie.setSecure(isSecure); cookie.setPath(stateCookiePath); response.addCookie(cookie); response.sendRedirect(url.toString()); } protected String getCookieValue(String name, HttpServletRequest request) { if (request.getCookies() == null) return null; for (Cookie cookie : request.getCookies()) { if (cookie.getName().equals(name)) return cookie.getValue(); } return null; } protected String getCode(HttpServletRequest request) { String query = request.getQueryString(); if (query == null) return null; String[] params = query.split("&"); for (String param : params) { int eq = param.indexOf('='); if (eq == -1) continue; String name = param.substring(0, eq); if (!name.equals(OAuth2Constants.CODE)) continue; return param.substring(eq + 1); } return null; } /** * Obtain the code parameter from the url after being redirected back from the auth-server. Then * do an authenticated request back to the auth-server to turn the access code into an access token. * * @param request * @return * @throws IOException * @throws org.keycloak.adapters.ServerRequest.HttpFailure */ public AccessTokenResponse getBearerToken(HttpServletRequest request) throws IOException, ServerRequest.HttpFailure { String error = request.getParameter(OAuth2Constants.ERROR); if (error != null) throw new IOException("OAuth error: " + error); String redirectUri = request.getRequestURL().append("?").append(request.getQueryString()).toString(); String stateCookie = getCookieValue(stateCookieName, request); if (stateCookie == null) throw new IOException("state cookie not set"); // we can call get parameter as this should be a redirect String state = request.getParameter(OAuth2Constants.STATE); String code = request.getParameter(OAuth2Constants.CODE); if (state == null) throw new IOException("state parameter was null"); if (!state.equals(stateCookie)) { throw new IOException("state parameter invalid"); } if (code == null) throw new IOException("code parameter was null"); return resolveBearerToken(request, redirectUri, code); } public AccessTokenResponse refreshToken(HttpServletRequest request, String refreshToken) throws IOException, ServerRequest.HttpFailure { KeycloakDeployment resolvedDeployment = resolveDeployment(getDeployment(), request); return ServerRequest.invokeRefresh(resolvedDeployment, refreshToken); } public static IDToken extractIdToken(String idToken) { if (idToken == null) return null; try { JWSInput input = new JWSInput(idToken); return input.readJsonContent(IDToken.class); } catch (JWSInputException e) { throw new RuntimeException(e); } } private KeycloakDeployment resolveDeployment(KeycloakDeployment baseDeployment, HttpServletRequest request) { ServletFacade facade = new ServletFacade(request); return new AdapterDeploymentContext(baseDeployment).resolveDeployment(facade); } public static class ServletFacade implements OIDCHttpFacade { private final HttpServletRequest servletRequest; private ServletFacade(HttpServletRequest servletRequest) { this.servletRequest = servletRequest; } @Override public KeycloakSecurityContext getSecurityContext() { throw new IllegalStateException("Not yet implemented"); } @Override public Request getRequest() { return new Request() { @Override public String getFirstParam(String param) { return servletRequest.getParameter(param); } @Override public String getMethod() { return servletRequest.getMethod(); } @Override public String getURI() { return servletRequest.getRequestURL().toString(); } @Override public String getRelativePath() { return servletRequest.getServletPath(); } @Override public boolean isSecure() { return servletRequest.isSecure(); } @Override public String getQueryParamValue(String param) { return servletRequest.getParameter(param); } @Override public Cookie getCookie(String cookieName) { // TODO return null; } @Override public String getHeader(String name) { return servletRequest.getHeader(name); } @Override public List<String> getHeaders(String name) { // TODO return null; } @Override public InputStream getInputStream() { try { return servletRequest.getInputStream(); } catch (IOException ioe) { throw new RuntimeException(ioe); } } @Override public String getRemoteAddr() { return servletRequest.getRemoteAddr(); } @Override public void setError(AuthenticationError error) { servletRequest.setAttribute(AuthenticationError.class.getName(), error); } @Override public void setError(LogoutError error) { servletRequest.setAttribute(LogoutError.class.getName(), error); } }; } @Override public Response getResponse() { throw new IllegalStateException("Not yet implemented"); } @Override public X509Certificate[] getCertificateChain() { throw new IllegalStateException("Not yet implemented"); } } }