/*
* 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.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.rotation.AdapterRSATokenVerifier;
import org.keycloak.adapters.spi.AdapterSessionStore;
import org.keycloak.adapters.spi.AuthChallenge;
import org.keycloak.adapters.spi.AuthOutcome;
import org.keycloak.adapters.spi.HttpFacade;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Encode;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.UriUtils;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.enums.TokenStore;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.util.TokenUtil;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class OAuthRequestAuthenticator {
private static final Logger log = Logger.getLogger(OAuthRequestAuthenticator.class);
protected KeycloakDeployment deployment;
protected RequestAuthenticator reqAuthenticator;
protected int sslRedirectPort;
protected AdapterSessionStore tokenStore;
protected String tokenString;
protected String idTokenString;
protected IDToken idToken;
protected AccessToken token;
protected HttpFacade facade;
protected AuthChallenge challenge;
protected String refreshToken;
protected String strippedOauthParametersRequestUri;
public OAuthRequestAuthenticator(RequestAuthenticator requestAuthenticator, HttpFacade facade, KeycloakDeployment deployment, int sslRedirectPort, AdapterSessionStore tokenStore) {
this.reqAuthenticator = requestAuthenticator;
this.facade = facade;
this.deployment = deployment;
this.sslRedirectPort = sslRedirectPort;
this.tokenStore = tokenStore;
}
public AuthChallenge getChallenge() {
return challenge;
}
public String getTokenString() {
return tokenString;
}
public AccessToken getToken() {
return token;
}
public String getRefreshToken() {
return refreshToken;
}
public String getIdTokenString() {
return idTokenString;
}
public void setIdTokenString(String idTokenString) {
this.idTokenString = idTokenString;
}
public IDToken getIdToken() {
return idToken;
}
public void setIdToken(IDToken idToken) {
this.idToken = idToken;
}
public String getStrippedOauthParametersRequestUri() {
return strippedOauthParametersRequestUri;
}
public void setStrippedOauthParametersRequestUri(String strippedOauthParametersRequestUri) {
this.strippedOauthParametersRequestUri = strippedOauthParametersRequestUri;
}
protected String getRequestUrl() {
return facade.getRequest().getURI();
}
protected boolean isRequestSecure() {
return facade.getRequest().isSecure();
}
protected OIDCHttpFacade.Cookie getCookie(String cookieName) {
return facade.getRequest().getCookie(cookieName);
}
protected String getCookieValue(String cookieName) {
OIDCHttpFacade.Cookie cookie = getCookie(cookieName);
if (cookie == null) return null;
return cookie.getValue();
}
protected String getQueryParamValue(String paramName) {
return facade.getRequest().getQueryParamValue(paramName);
}
protected String getError() {
return getQueryParamValue(OAuth2Constants.ERROR);
}
protected String getCode() {
return getQueryParamValue(OAuth2Constants.CODE);
}
protected String getRedirectUri(String state) {
String url = getRequestUrl();
log.debugf("callback uri: %s", url);
if (!facade.getRequest().isSecure() && deployment.getSslRequired().isRequired(facade.getRequest().getRemoteAddr())) {
int port = sslRedirectPort();
if (port < 0) {
// disabled?
return null;
}
KeycloakUriBuilder secureUrl = KeycloakUriBuilder.fromUri(url).scheme("https").port(-1);
if (port != 443) secureUrl.port(port);
url = secureUrl.build().toString();
}
String loginHint = getQueryParamValue("login_hint");
url = UriUtils.stripQueryParam(url,"login_hint");
String idpHint = getQueryParamValue(AdapterConstants.KC_IDP_HINT);
url = UriUtils.stripQueryParam(url, AdapterConstants.KC_IDP_HINT);
String scope = getQueryParamValue(OAuth2Constants.SCOPE);
url = UriUtils.stripQueryParam(url, OAuth2Constants.SCOPE);
String prompt = getQueryParamValue(OAuth2Constants.PROMPT);
url = UriUtils.stripQueryParam(url, OAuth2Constants.PROMPT);
String maxAge = getQueryParamValue(OAuth2Constants.MAX_AGE);
url = UriUtils.stripQueryParam(url, OAuth2Constants.MAX_AGE);
KeycloakUriBuilder redirectUriBuilder = deployment.getAuthUrl().clone()
.queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE)
.queryParam(OAuth2Constants.CLIENT_ID, deployment.getResourceName())
.queryParam(OAuth2Constants.REDIRECT_URI, Encode.encodeQueryParamAsIs(url)) // Need to encode uri ourselves as queryParam() will not encode % characters.
.queryParam(OAuth2Constants.STATE, state)
.queryParam("login", "true");
if(loginHint != null && loginHint.length() > 0){
redirectUriBuilder.queryParam("login_hint",loginHint);
}
if (idpHint != null && idpHint.length() > 0) {
redirectUriBuilder.queryParam(AdapterConstants.KC_IDP_HINT,idpHint);
}
if (prompt != null && prompt.length() > 0) {
redirectUriBuilder.queryParam(OAuth2Constants.PROMPT, prompt);
}
if (maxAge != null && maxAge.length() > 0) {
redirectUriBuilder.queryParam(OAuth2Constants.MAX_AGE, maxAge);
}
scope = TokenUtil.attachOIDCScope(scope);
redirectUriBuilder.queryParam(OAuth2Constants.SCOPE, scope);
return redirectUriBuilder.build().toString();
}
protected int sslRedirectPort() {
return sslRedirectPort;
}
protected String getStateCode() {
return AdapterUtils.generateId();
}
protected AuthChallenge loginRedirect() {
final String state = getStateCode();
final String redirect = getRedirectUri(state);
if (redirect == null) {
return challenge(403, OIDCAuthenticationError.Reason.NO_REDIRECT_URI, null);
}
return new AuthChallenge() {
@Override
public int getResponseCode() {
return 0;
}
@Override
public boolean challenge(HttpFacade exchange) {
tokenStore.saveRequest();
log.debug("Sending redirect to login page: " + redirect);
exchange.getResponse().setStatus(302);
exchange.getResponse().setCookie(deployment.getStateCookieName(), state, /* need to set path? */ null, null, -1, deployment.getSslRequired().isRequired(facade.getRequest().getRemoteAddr()), true);
exchange.getResponse().setHeader("Location", redirect);
return true;
}
};
}
protected AuthChallenge checkStateCookie() {
OIDCHttpFacade.Cookie stateCookie = getCookie(deployment.getStateCookieName());
if (stateCookie == null) {
log.warn("No state cookie");
return challenge(400, OIDCAuthenticationError.Reason.INVALID_STATE_COOKIE, null);
}
// reset the cookie
log.debug("** reseting application state cookie");
facade.getResponse().resetCookie(deployment.getStateCookieName(), stateCookie.getPath());
String stateCookieValue = getCookieValue(deployment.getStateCookieName());
String state = getQueryParamValue(OAuth2Constants.STATE);
if (state == null) {
log.warn("state parameter was null");
return challenge(400, OIDCAuthenticationError.Reason.INVALID_STATE_COOKIE, null);
}
if (!state.equals(stateCookieValue)) {
log.warn("state parameter invalid");
log.warn("cookie: " + stateCookieValue);
log.warn("queryParam: " + state);
return challenge(400, OIDCAuthenticationError.Reason.INVALID_STATE_COOKIE, null);
}
return null;
}
public AuthOutcome authenticate() {
String code = getCode();
if (code == null) {
log.debug("there was no code");
String error = getError();
if (error != null) {
// todo how do we send a response?
log.warn("There was an error: " + error);
challenge = challenge(400, OIDCAuthenticationError.Reason.OAUTH_ERROR, error);
return AuthOutcome.FAILED;
} else {
log.debug("redirecting to auth server");
challenge = loginRedirect();
return AuthOutcome.NOT_ATTEMPTED;
}
} else {
log.debug("there was a code, resolving");
challenge = resolveCode(code);
if (challenge != null) {
return AuthOutcome.FAILED;
}
return AuthOutcome.AUTHENTICATED;
}
}
protected AuthChallenge challenge(final int code, final OIDCAuthenticationError.Reason reason, final String description) {
return new AuthChallenge() {
@Override
public int getResponseCode() {
return code;
}
@Override
public boolean challenge(HttpFacade exchange) {
OIDCAuthenticationError error = new OIDCAuthenticationError(reason, description);
exchange.getRequest().setError(error);
exchange.getResponse().sendError(code);
return true;
}
};
}
/**
* Start or continue the oauth login process.
* <p/>
* if code query parameter is not present, then browser is redirected to authUrl. The redirect URL will be
* the URL of the current request.
* <p/>
* If code query parameter is present, then an access token is obtained by invoking a secure request to the codeUrl.
* If the access token is obtained, the browser is again redirected to the current request URL, but any OAuth
* protocol specific query parameters are removed.
*
* @return null if an access token was obtained, otherwise a challenge is returned
*/
protected AuthChallenge resolveCode(String code) {
// abort if not HTTPS
if (!isRequestSecure() && deployment.getSslRequired().isRequired(facade.getRequest().getRemoteAddr())) {
log.error("Adapter requires SSL. Request: " + facade.getRequest().getURI());
return challenge(403, OIDCAuthenticationError.Reason.SSL_REQUIRED, null);
}
log.debug("checking state cookie for after code");
AuthChallenge challenge = checkStateCookie();
if (challenge != null) return challenge;
AccessTokenResponse tokenResponse = null;
strippedOauthParametersRequestUri = stripOauthParametersFromRedirect();
try {
// For COOKIE store we don't have httpSessionId and single sign-out won't be available
String httpSessionId = deployment.getTokenStore() == TokenStore.SESSION ? reqAuthenticator.changeHttpSessionId(true) : null;
tokenResponse = ServerRequest.invokeAccessCodeToToken(deployment, code, strippedOauthParametersRequestUri, httpSessionId);
} catch (ServerRequest.HttpFailure failure) {
log.error("failed to turn code into token");
log.error("status from server: " + failure.getStatus());
if (failure.getStatus() == 400 && failure.getError() != null) {
log.error(" " + failure.getError());
}
return challenge(403, OIDCAuthenticationError.Reason.CODE_TO_TOKEN_FAILURE, null);
} catch (IOException e) {
log.error("failed to turn code into token", e);
return challenge(403, OIDCAuthenticationError.Reason.CODE_TO_TOKEN_FAILURE, null);
}
tokenString = tokenResponse.getToken();
refreshToken = tokenResponse.getRefreshToken();
idTokenString = tokenResponse.getIdToken();
try {
token = AdapterRSATokenVerifier.verifyToken(tokenString, deployment);
if (idTokenString != null) {
try {
JWSInput input = new JWSInput(idTokenString);
idToken = input.readJsonContent(IDToken.class);
} catch (JWSInputException e) {
throw new VerificationException();
}
}
log.debug("Token Verification succeeded!");
} catch (VerificationException e) {
log.error("failed verification of token: " + e.getMessage());
return challenge(403, OIDCAuthenticationError.Reason.INVALID_TOKEN, null);
}
if (tokenResponse.getNotBeforePolicy() > deployment.getNotBefore()) {
deployment.updateNotBefore(tokenResponse.getNotBeforePolicy());
}
if (token.getIssuedAt() < deployment.getNotBefore()) {
log.error("Stale token");
return challenge(403, OIDCAuthenticationError.Reason.STALE_TOKEN, null);
}
log.debug("successful authenticated");
return null;
}
/**
* strip out unwanted query parameters and redirect so bookmarks don't retain oauth protocol bits
*/
protected String stripOauthParametersFromRedirect() {
KeycloakUriBuilder builder = KeycloakUriBuilder.fromUri(facade.getRequest().getURI())
.replaceQueryParam(OAuth2Constants.CODE, null)
.replaceQueryParam(OAuth2Constants.STATE, null);
return builder.build().toString();
}
}