package org.bsworks.catalina.authenticator.oidc; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.Reader; import java.io.StringReader; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.nio.charset.Charset; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.SecureRandom; import java.security.Signature; import java.security.SignatureException; import java.text.DateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Date; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.catalina.LifecycleException; import org.apache.catalina.Session; import org.apache.catalina.authenticator.Constants; import org.apache.catalina.authenticator.FormAuthenticator; import org.apache.catalina.authenticator.SavedRequest; import org.apache.catalina.connector.Request; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.util.buf.HexUtils; import org.apache.tomcat.util.descriptor.web.LoginConfig; import org.bsworks.util.json.JSONArray; import org.bsworks.util.json.JSONException; import org.bsworks.util.json.JSONObject; import org.bsworks.util.json.JSONTokener; /** * Base <em>OpenID Connect</em> authenticator implementation for different * versions of <em>Tomcat</em>. * * @author Lev Himmelfarb */ public abstract class BaseOpenIDConnectAuthenticator extends FormAuthenticator { /** * Authorization endpoint descriptor for the login page. */ public static final class AuthEndpointDesc { /** * OP name. */ private final String name; /** * Issuer ID. */ private final String issuer; /** * Endpoint URL. */ private final String url; /** * Create new descriptor. * * @param name OP name. * @param issuer Issuer ID. * @param url Endpoint URL. */ AuthEndpointDesc(final String name, final String issuer, final String url) { this.name = name; this.issuer = issuer; this.url = url; } /** * Get OP name. * * @return The OP name. */ public String getName() { return this.name; } /** * Get issuer ID. * * @return The issuer ID. */ public String getIssuer() { return this.issuer; } /** * Get endpoint URL. * * @return The URL. */ public String getUrl() { return this.url; } } /** * Authentication error descriptor for the error page. */ public static final class AuthErrorDesc { /** * Error code. */ final String code; /** * Optional error description. */ final String description; /** * Optional URI of the page with the error information. */ final String infoPageURI; /** * Create new descriptor using request parameters. * * @param request The request representing the error response. */ AuthErrorDesc(final Request request) { this.code = request.getParameter("error"); this.description = request.getParameter("error_description"); this.infoPageURI = request.getParameter("error_uri"); } /** * Create new descriptor using endpoint error response JSON. * * @param error The error response JSON. */ AuthErrorDesc(final JSONObject error) { this.code = error.getString("error"); this.description = error.optString("error_description", null); this.infoPageURI = error.optString("error_uri", null); } /** * Get error code. * * @return The code. */ public String getCode() { return this.code; } /** * Get optional error description. * * @return The description, or {@code null}. */ public String getDescription() { return this.description; } /** * Get optional URI of the page containing the error information. * * @return The page URI, or {@code null}. */ public String getInfoPageURI() { return this.infoPageURI; } } /** * The successful authorization information derived from the token endpoint * response. */ public static final class Authorization { /** * Issuer ID. */ private final String issuer; /** * Timestamp when the authorization was issued. */ private final Date issuedAt; /** * Access token. */ private final String accessToken; /** * Token type. */ private final String tokenType; /** * Seconds to the authorization (access token) expiration. */ private final int expiresIn; /** * Optional refresh token. */ private final String refreshToken; /** * Optional scope. */ private final String scope; /** * ID token. */ private final String idToken; /** * Create new authorization descriptor. * * @param issuer Issuer ID. * @param issuedAt Timestamp when the authorization was issued. * @param tokenResponse Successful token endpoint response document. */ Authorization(final String issuer, final Date issuedAt, final JSONObject tokenResponse) { this.issuer = issuer; this.issuedAt = issuedAt; this.accessToken = tokenResponse.optString("access_token", null); this.tokenType = tokenResponse.optString("token_type", null); this.expiresIn = tokenResponse.optInt("expires_in", -1); this.refreshToken = tokenResponse.optString("refresh_token", null); this.scope = tokenResponse.optString("scope", null); this.idToken = tokenResponse.getString("id_token"); } /** * Get Issuer Identifier. * * @return The issuer ID. */ public String getIssuer() { return this.issuer; } /** * Get timestamp when the authorization was issued. * * @return The timestamp (milliseconds). */ public Date getIssuedAt() { return this.issuedAt; } /** * Get access token. * * @return The access token. */ public String getAccessToken() { return this.accessToken; } /** * Get access token type (e.g. "Bearer"). * * @return Access token type. */ public String getTokenType() { return this.tokenType; } /** * Get access token expiration. * * @return Seconds after which the authorization (the access token) * expires, or -1 if unspecified. */ public int getExpiresIn() { return this.expiresIn; } /** * Get optional refresh token. * * @return The refresh token, or {@code null} if none. */ public String getRefreshToken() { return this.refreshToken; } /** * Get optional scope. * * @return The scope, or {@code null} if none. */ public String getScope() { return this.scope; } /** * Get ID token. * * @return The ID token. */ public String getIdToken() { return this.idToken; } /* (non-Javadoc) * See overridden method. */ @Override public String toString() { final StringBuilder buf = new StringBuilder(1024); buf.append("Authorization issued at ") .append(DateFormat.getDateTimeInstance().format(this.issuedAt)) .append(" by ").append(this.issuer).append(":"); buf.append("\n accessToken: ").append(this.accessToken); buf.append("\n tokenType: ").append(this.tokenType); buf.append("\n expiresIn: ").append(this.expiresIn) .append(" seconds"); buf.append("\n refreshToken: ").append(this.refreshToken); buf.append("\n scope: ").append(this.scope); buf.append("\n idToken: ").append(this.idToken); return buf.toString(); } } /** * OP token endpoint response. */ private static final class TokenEndpointResponse { /** * Response HTTP status code. */ final int responseCode; /** * Response date. */ final Date responseDate; /** * Response body. */ final JSONObject responseBody; /** * Create new object representing a response. * * @param responseCode Response HTTP status code. * @param responseDate Response date. * @param responseBody Response body. */ TokenEndpointResponse(final int responseCode, final long responseDate, final JSONObject responseBody) { this.responseCode = responseCode; this.responseDate = new Date( responseDate != 0 ? responseDate : System.currentTimeMillis()); this.responseBody = responseBody; } /* (non-Javadoc) * @see java.lang.Object#toString() */ @Override public String toString() { return "status: " + this.responseCode + ", date: " + DateFormat.getDateTimeInstance().format(this.responseDate) + ", body: " + this.responseBody; } } /** * Authenticated user descriptor. */ private static final class AuthedUser { /** * Principal. */ final Principal principal; /** * Username. */ final String username; /** * Password, if any. */ final String password; /** * Create new authenticated user descriptor. * * @param principal Principal. * @param username Username. * @param password Password, or {@code null} if not applicable. */ AuthedUser(final Principal principal, final String username, final String password) { this.principal = principal; this.username = username; this.password = password; } } /** * Name of request attribute made available to the login page that maps * configured OP issuer IDs to the corresponding authorization endpoint * URLs. */ public static final String AUTHEPS_ATT = "org.bsworks.oidc.authEndpoints"; /** * Name of request attribute made available to the login page that tells if * the form-based authentication is disabled. */ public static final String NOFORM_ATT = "org.bsworks.oidc.noForm"; /** * Name of request attribute made available on the login error page that * contains the error descriptor. */ public static final String AUTHERROR_ATT = "org.bsworks.oidc.error"; /** * Name of session attribute used to store the {@link Authorization} object. */ public static final String AUTHORIZATION_ATT = "org.bsworks.oidc.authorization"; /** * UTF-8 charset. */ private static final Charset UTF8 = Charset.forName("UTF-8"); /** * URL-safe base64 decoder. */ private static final Base64.Decoder BASE64URL_DECODER = Base64.getUrlDecoder(); /** * Base64 encoder. */ private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder(); /** * Name of the HTTP session note used to store the {@link Authorization} * object. */ private static final String SESS_OIDC_AUTH_NOTE = "org.bsworks.catalina.session.AUTHORIZATION"; /** * Name of the HTTP session note used to store the state value. */ private static final String SESS_STATE_NOTE = "org.bsworks.catalina.session.STATE"; /** * Pattern for the state parameter. */ private static final Pattern STATE_PATTERN = Pattern.compile( "^(\\d+)Z(.+)"); /** * Pattern used to parse providers configuration and convert it into JSON. */ private static final Pattern OP_CONF_LINE_PATTERN = Pattern.compile( "(\\w+)\\s*:\\s*(?:'([^']*)'|([^\\s,{}]+))"); /** * The log. */ protected final Log log = LogFactory.getLog(this.getClass()); /** * Virtual host base URI. */ protected String hostBaseURI; /** * Providers configuration. */ protected String providers; /** * Name of the claim in the ID Token used as the username in the users * realm. Can be overridden for specific OPs. */ protected String usernameClaim = "sub"; /** * Space separated list of scopes to add to "openid" scope in the * authorization endpoint request. Can be overridden for specific OPs. */ protected String additionalScopes; /** * Tells if the form-based authentication is disabled. */ protected boolean noForm = false; /** * HTTP connect timeout for OP endpoints. */ protected int httpConnectTimeout = 5000; /** * HTTP read timeout for OP endpoints. */ protected int httpReadTimeout = 5000; /** * Secure random number generator. */ private final SecureRandom rand = new SecureRandom(); /** * Configured OpenID Connect Provider descriptors. */ private List<OPDescriptor> opDescs; /** * OpenID Connect Provider configurations provider. */ private OPConfigurationsProvider ops; /** * Get virtual host base URI property. * * @return Host base URI. */ public String getHostBaseURI() { return this.hostBaseURI; } /** * Set virtual host base URI property. The URI is used when constructing * callback URLs for the web-application. If not set, the authenticator will * attempt to construct it using the requests it receives. * * @param hostBaseURI Host base URI. Must not end with a "/". Should be an * HTTPS URI. */ public void setHostBaseURI(final String hostBaseURI) { this.hostBaseURI = hostBaseURI; } /** * Get providers configuration. * * @return The providers configuration */ public String getProviders() { return this.providers; } /** * Set providers configuration. * * @param providers The providers configuration, which is a JSON-like array * of descriptors, one for each configured provider. Unlike standard JSON, * the syntax does not use double quotes around the property names and * values (to make it XML attribute value friendly). The value can be * surrounded with single quotes if it contains commas, curly braces or * whitespace. */ public void setProviders(final String providers) { this.providers = providers; } /** * Get name of the claim in the ID Token used as the username. * * @return The claim name. */ public String getUsernameClaim() { return this.usernameClaim; } /** * Set name of the claim in the ID Token used as the username in the users * realm. The default is "sub". * * @param usernameClaim The claim name. */ public void setUsernameClaim(final String usernameClaim) { this.usernameClaim = usernameClaim; } /** * Get additional scopes for the authorization endpoint. * * @return The additional scopes. */ public String getAdditionalScopes() { return this.additionalScopes; } /** * Set additional scopes for the authorization endpoint. The scopes are * added to the required "openid" scope, which is always included. * * @param additionalScopes The additional scopes as a space separated list. */ public void setAdditionalScopes(final String additionalScopes) { this.additionalScopes = additionalScopes; } /** * Tell if form-based authentication is disabled. * * @return {@code true} if disabled. */ public boolean isNoForm() { return this.noForm; } /** * Set flag that tells if the form-based authentication should be disabled. * * @param noForm {@code true} to disabled form-based authentication. */ public void setNoForm(final boolean noForm) { this.noForm = noForm; } /** * Get HTTP connect timeout used for server-to-server communication with the * OpenID Connect provider. * * @return Timeout in milliseconds. */ public int getHttpConnectTimeout() { return this.httpConnectTimeout; } /** * Set HTTP connect timeout used for server-to-server communication with the * OpenID Connect provider. The default is 5000. * * @param httpConnectTimeout Timeout in milliseconds. * * @see URLConnection#setConnectTimeout(int) */ public void setHttpConnectTimeout(final int httpConnectTimeout) { this.httpConnectTimeout = httpConnectTimeout; } /** * Get HTTP read timeout used for server-to-server communication with the * OpenID Connect provider. * * @return Timeout in milliseconds. */ public int getHttpReadTimeout() { return this.httpReadTimeout; } /** * Set HTTP read timeout used for server-to-server communication with the * OpenID Connect provider. The default is 5000. * * @param httpReadTimeout Timeout in milliseconds. * * @see URLConnection#setReadTimeout(int) */ public void setHttpReadTimeout(final int httpReadTimeout) { this.httpReadTimeout = httpReadTimeout; } /* (non-Javadoc) * See overridden method. */ @Override protected synchronized void startInternal() throws LifecycleException { // verify Tomcat version this.ensureTomcatVersion(); // verify that providers are configured if (this.providers == null) throw new LifecycleException("OpenIDConnectAuthenticator requires" + " \"providers\" property."); // parse provider definitions and create the configurations provider final String providersConf = this.providers.trim(); if (providersConf.startsWith("[")) { final StringBuffer providersConfJSONBuf = new StringBuffer(512); final Matcher m = OP_CONF_LINE_PATTERN.matcher(providersConf); while (m.find()) { m.appendReplacement(providersConfJSONBuf, Matcher.quoteReplacement( "\"" + m.group(1) + "\": \"" + (m.group(2) != null ? m.group(2) : m.group(3)) + "\"")); } m.appendTail(providersConfJSONBuf); final String providersConfJSON = providersConfJSONBuf.toString(); try { this.log.debug( "parsing configuration JSON: " + providersConfJSON); final JSONArray opDefs = new JSONArray(new JSONTokener( new StringReader(providersConfJSON))); final int numOPs = opDefs.length(); this.opDescs = new ArrayList<>(numOPs); for (int i = 0; i < numOPs; i++) { final Object opDef = opDefs.opt(i); if ((opDef == null) || !(opDef instanceof JSONObject)) throw new LifecycleException("Expected an object at" + " OpenIDConnectAuthenticator \"providers\"" + " array element " + i + "."); this.opDescs.add(new OPDescriptor((JSONObject) opDef, this.usernameClaim, this.additionalScopes)); } } catch (final IOException | JSONException e) { throw new LifecycleException("OpenIDConnectAuthenticator could" + " not parse \"providers\" property.", e); } } else { // deprecated syntax this.opDescs = this.parseDeprecatedOPDefs(providersConf); } this.ops = new OPConfigurationsProvider(this.opDescs); // preload provider configurations and detect any errors try { for (final OPDescriptor opDesc : this.opDescs) this.ops.getOPConfiguration(opDesc.getIssuer()); } catch (final IOException | JSONException e) { throw new LifecycleException("OpenIDConnectAuthenticator could not" + " load OpenID Connect Provider configuration.", e); } // proceed with initialization super.startInternal(); } /** * Verify that the authenticator is running under a compatible version of * Tomcat. * * @throws LifecycleException If the Tomcat version is incompatible. */ protected abstract void ensureTomcatVersion() throws LifecycleException; /** * Parse deprecated OP configuration syntax. * * @param providersConf Configuration in deprecated syntax. * @return The OP descriptors. */ @SuppressWarnings("deprecation") private List<OPDescriptor> parseDeprecatedOPDefs( final String providersConf) { final String[] defs = providersConf.split("\\s+"); final List<OPDescriptor> descs = new ArrayList<>(defs.length); for (final String def : defs) descs.add(new OPDescriptor(def, this.usernameClaim, this.additionalScopes)); return descs; } /** * Perform authentication. * * @param request The request. * @param response The response. * * @return Authentication result. * * @throws IOException If an I/O error happens. */ protected boolean performAuthentication(final Request request, final HttpServletResponse response) throws IOException { final boolean debug = this.log.isDebugEnabled(); // try to reauthenticate if caching principal is disabled if (!this.cache && this.reauthenticateNoCache(request, response)) return true; // check if resubmit after successful authentication if (this.matchRequest(request)) return this.processResubmit(request, response); // check if already authenticated if (this.checkForCachedAuthentication(request, response, true)) return true; // the request is not authenticated: // determine if authentication submission final String requestURI = request.getDecodedRequestURI(); final boolean loginAction = ( requestURI.startsWith(request.getContextPath()) && requestURI.endsWith(Constants.FORM_ACTION)); // check if regular unauthenticated request, not a submission if (!loginAction) { this.processUnauthenticated(request, response); return false; } // authentication submission (either form or OP response redirect): // acknowledge the request request.getResponse().sendAcknowledgement(); // set response character encoding if (this.characterEncoding != null) request.setCharacterEncoding(this.characterEncoding); // get current session and check if expired final Session session = request.getSessionInternal(false); if (session == null) { // log using container log (why container?) this.log.debug("user took so long to log on the session expired"); // process expired session this.processExpiredSession(request, response); // done, authentication failure return false; } if (debug) this.log.debug("existing session id " + session.getId()); // the authenticated user AuthedUser authedUser = null; // check if OIDC authentication response or form submission if ((request.getParameter("code") != null) || (request.getParameter("error") != null)) { authedUser = this.processAuthResponse(session, request); } else if (!this.noForm) { // form submission authedUser = this.processAuthFormSubmission(session, request.getParameter(Constants.FORM_USERNAME), request.getParameter(Constants.FORM_PASSWORD)); } // check if authentication failure if (authedUser == null) { this.forwardToErrorPage(request, response, this.context.getLoginConfig()); return false; } // successful authentication if (debug) this.log.debug("authentication of \"" + authedUser.principal.getName() + "\" was successful"); // change session id (to prevent a session fixation attack) if (this.getChangeSessionIdOnAuthentication()) { final String expectedSessionId = (String) session.getNote( Constants.SESSION_ID_NOTE); if ((expectedSessionId == null) || !expectedSessionId.equals( request.getRequestedSessionId())) { if (debug) this.log.debug("unable to change session id" + ", expiring the session: expected session id is " + expectedSessionId + ", requested session id is " + request.getRequestedSessionId()); session.expire(); this.processExpiredSession(request, response); return false; } } // save the authenticated principal in our session this.register(request, response, authedUser.principal, HttpServletRequest.FORM_AUTH, authedUser.username, authedUser.password); // get the original unauthenticated request URI final String origRequestURI = this.savedRequestURL(session); if (debug) this.log.debug("redirecting to original URI: " + origRequestURI); // if (somehow!) original URI is unavailable, go to the landing page if (origRequestURI == null) { if (!this.redirectToLandingPage(request, response)) response.sendError(HttpServletResponse.SC_BAD_REQUEST, sm.getString("authenticator.formlogin")); return false; } // redirect to the original URI request.getResponse().sendRedirect( response.encodeRedirectURL(origRequestURI), ("HTTP/1.1".equals(request.getProtocol()) ? HttpServletResponse.SC_SEE_OTHER : HttpServletResponse.SC_FOUND)); // done, will be authenticated after the redirect return false; } /** * If caching principal on the session by the authenticator is disabled, * check if the session has authentication information (username, password * or OP issuer ID) and if so, reauthenticate the user. * * @param request The request. * @param response The response. * * @return {@code true} if was successfully reauthenticated and no further * authentication action is required. If authentication logic should * proceed, returns {@code false}. */ protected boolean reauthenticateNoCache(final Request request, final HttpServletResponse response) { // get session final Session session = request.getSessionInternal(true); final boolean debug = this.log.isDebugEnabled(); if (debug) this.log.debug("checking for reauthenticate in session " + session.getIdInternal()); // check if authentication info is in the session final String username = (String) session.getNote(Constants.SESS_USERNAME_NOTE); if (username == null) return false; // get the rest of the authentication info final Authorization authorization = (Authorization) session.getNote(SESS_OIDC_AUTH_NOTE); final String password = (String) session.getNote(Constants.SESS_PASSWORD_NOTE); // get the principal from the realm (try to reauthenticate) Principal principal = null; if (authorization != null) { // was authenticated using OpenID Connect if (debug) this.log.debug("reauthenticating username \"" + username + "\" authenticated by " + authorization.getIssuer()); principal = this.context.getRealm().authenticate( username); } else if (password != null) { // was form-based authentication if (debug) this.log.debug("reauthenticating username \"" + username + "\" using password"); principal = this.context.getRealm().authenticate( username, password); } // check if could not reauthenticate if (principal == null) { if (debug) this.log.debug("reauthentication failed, proceed normally"); return false; } // successfully reauthenticated, register the principal if (debug) this.log.debug("successfully reauthenticated username \"" + username + "\""); this.register(request, response, principal, HttpServletRequest.FORM_AUTH, username, password); // check if resubmit after successful authentication if (this.matchRequest(request)) { if (debug) this.log.debug("reauthenticated username \"" + username + "\" for resubmit after successful authentication"); return false; } // no further authentication action required return true; } /** * Process original request resubmit after successful authentication. * * @param request The request. * @param response The response. * * @return {@code true} if success, {@code false} if failure, in which case * an HTTP 400 response is sent back by this method. * * @throws IOException If an I/O error happens communicating with the * client. */ protected boolean processResubmit(final Request request, final HttpServletResponse response) throws IOException { // get session final Session session = request.getSessionInternal(true); final boolean debug = this.log.isDebugEnabled(); if (debug) this.log.debug("restore request from session " + session.getIdInternal()); // if principal is cached, remove authentication info from the session if (this.cache) { session.removeNote(Constants.SESS_USERNAME_NOTE); session.removeNote(Constants.SESS_PASSWORD_NOTE); session.removeNote(SESS_OIDC_AUTH_NOTE); } // try to restore original request if (!this.restoreRequest(request, session)) { if (debug) this.log.debug("restore of original request failed"); response.sendError(HttpServletResponse.SC_BAD_REQUEST); return false; } // all good, no further authentication action is required if (debug) this.log.debug("proceed to restored request"); return true; } /** * Process regular unauthenticated request. Normally, saves the request in * the session and forwards to the configured login page. * * @param request The request. * @param response The response. * * @throws IOException If an I/O error happens communicating with the * client. */ protected void processUnauthenticated(final Request request, final HttpServletResponse response) throws IOException { // If this request was to the root of the context without a trailing // "/", need to redirect to add it else the submit of the login form // may not go to the correct web application if ((request.getServletPath().length() == 0) && (request.getPathInfo() == null)) { final StringBuilder location = new StringBuilder( request.getDecodedRequestURI()); location.append('/'); if (request.getQueryString() != null) location.append('?').append(request.getQueryString()); response.sendRedirect( response.encodeRedirectURL(location.toString())); return; } // get session final Session session = request.getSessionInternal(true); final boolean debug = this.log.isDebugEnabled(); if (debug) this.log.debug("save request in session " + session.getIdInternal()); // save original request in the session before forwarding to the login try { this.saveRequest(request, session); } catch (final IOException e) { this.log.debug("could not save request during authentication", e); response.sendError(HttpServletResponse.SC_FORBIDDEN, sm.getString("authenticator.requestBodyTooBig")); return; } // forward to the login page this.forwardToLoginPage(request, response, this.context.getLoginConfig()); } /* (non-Javadoc) * See overridden method. */ @Override protected void forwardToLoginPage(final Request request, final HttpServletResponse response, final LoginConfig config) throws IOException { // add login configuration request attributes for the page this.addLoginConfiguration(request); // proceed to the login page super.forwardToLoginPage(request, response, config); } /* (non-Javadoc) * See overridden method. */ @Override protected void forwardToErrorPage(final Request request, final HttpServletResponse response, final LoginConfig config) throws IOException { // add login configuration request attributes for the page this.addLoginConfiguration(request); // proceed to the login error page super.forwardToErrorPage(request, response, config); } /** * Add request attributes for the login or the login error page. * * @param request The request. * * @throws IOException If an I/O error happens. */ protected void addLoginConfiguration(final Request request) throws IOException { // generate state value and save it in the session final byte[] stateBytes = new byte[16]; this.rand.nextBytes(stateBytes); final String state = HexUtils.toHexString(stateBytes); request.getSessionInternal(true).setNote(SESS_STATE_NOTE, state); // add OP authorization endpoints to the request for the login page final List<AuthEndpointDesc> authEndpoints = new ArrayList<>(); final StringBuilder buf = new StringBuilder(128); for (int i = 0; i < this.opDescs.size(); i++) { final OPDescriptor opDesc = this.opDescs.get(i); // get the OP configuration final String issuer = opDesc.getIssuer(); final OPConfiguration opConfig = this.ops.getOPConfiguration(issuer); // construct the authorization endpoint URL buf.setLength(0); buf.append(opConfig.getAuthorizationEndpoint()); buf.append("?scope=openid"); final String extraScopes = opDesc.getAdditionalScopes(); if (extraScopes != null) buf.append(URLEncoder.encode(" " + extraScopes, UTF8.name())); buf.append("&response_type=code"); buf.append("&client_id=").append(URLEncoder.encode( opDesc.getClientId(), UTF8.name())); buf.append("&redirect_uri=").append(URLEncoder.encode( this.getBaseURL(request) + Constants.FORM_ACTION, UTF8.name())); buf.append("&state=").append(i).append('Z').append(state); final String addlParams = opDesc.getExtraAuthEndpointParams(); if (addlParams != null) buf.append('&').append(addlParams); // add the URL to the map authEndpoints.add(new AuthEndpointDesc( opDesc.getName(), issuer, buf.toString())); } request.setAttribute(AUTHEPS_ATT, authEndpoints); // add no form flag to the request request.setAttribute(NOFORM_ATT, Boolean.valueOf(this.noForm)); } /** * Process login form submission. * * @param session The session. * @param username Submitted username. * @param password Submitted password. * * @return The authenticated user, or {@code null} if login failure. */ protected AuthedUser processAuthFormSubmission(final Session session, final String username, final String password) { final boolean debug = this.log.isDebugEnabled(); if (debug) this.log.debug("authenticating username \"" + username + "\" using password"); // authenticate principal in the realm final Principal principal = this.context.getRealm().authenticate(username, password); if (principal == null) { if (debug) this.log.debug("failed to authenticate the user in the realm"); return null; } // return the user descriptor return new AuthedUser(principal, username, password); } /** * Process the authentication response and authenticate the user. * * @param session The session. * @param request The request representing the authentication response. * * @return The authenticated user, or {@code null} if could not * authenticate. * * @throws IOException If an I/O error happens communicating with the OP. */ protected AuthedUser processAuthResponse(final Session session, final Request request) throws IOException { final boolean debug = this.log.isDebugEnabled(); if (debug) this.log.debug("authenticating user using OpenID Connect" + " authentication response"); // parse the state final String stateParam = request.getParameter("state"); if (stateParam == null) { if (debug) this.log.debug("no state in the authentication response"); return null; } final Matcher m = STATE_PATTERN.matcher(stateParam); if (!m.find()) { if (debug) this.log.debug("invalid state value in the authentication" + " response"); return null; } final int opInd = Integer.parseInt(m.group(1)); final String state = m.group(2); // get OP descriptor from the state if (opInd >= this.opDescs.size()) { if (debug) this.log.debug("authentication response state contains invalid" + " OP index"); return null; } final OPDescriptor opDesc = this.opDescs.get(opInd); final String issuer = opDesc.getIssuer(); if (debug) this.log.debug("processing authentication response from " + issuer); // match the session id from the state final String sessionState = (String) session.getNote(SESS_STATE_NOTE); session.removeNote(SESS_STATE_NOTE); if (!state.equals(sessionState)) { if (debug) this.log.debug("authentication response state does not match" + " the session id"); return null; } // check if error response final String errorCode = request.getParameter("error"); if (errorCode != null) { final AuthErrorDesc authError = new AuthErrorDesc(request); if (debug) this.log.debug("authentication error response: " + authError.getCode()); request.setAttribute(AUTHERROR_ATT, authError); return null; } // get the authorization code final String authCode = request.getParameter("code"); if (authCode == null) { if (debug) this.log.debug("no authorization code in the authentication" + " response"); return null; } // call the token endpoint, check if error and get the ID token final TokenEndpointResponse tokenResponse = this.callTokenEndpoint(opDesc, authCode, request); final String tokenErrorCode = tokenResponse.responseBody.optString("error"); if ((tokenResponse.responseCode != HttpURLConnection.HTTP_OK) || (tokenErrorCode.length() > 0)) { final AuthErrorDesc authError = new AuthErrorDesc(tokenResponse.responseBody); if (debug) this.log.debug("token error response: " + authError.getCode()); request.setAttribute(AUTHERROR_ATT, authError); return null; } // create the authorization object final Authorization authorization = new Authorization(issuer, tokenResponse.responseDate, tokenResponse.responseBody); // decode the ID token final String[] idTokenParts = authorization.getIdToken().split("\\."); final JSONObject idTokenHeader = new JSONObject(new JSONTokener( new StringReader(new String(BASE64URL_DECODER.decode( idTokenParts[0]), UTF8)))); final JSONObject idTokenPayload = new JSONObject(new JSONTokener( new StringReader(new String(BASE64URL_DECODER.decode( idTokenParts[1]), UTF8)))); final byte[] idTokenSignature = BASE64URL_DECODER.decode( idTokenParts[2]); if (debug) this.log.debug("decoded ID token:" + "\n header: " + idTokenHeader + "\n payload: " + idTokenPayload + "\n signature: " + Arrays.toString(idTokenSignature)); // validate the ID token: // validate issuer match if (!issuer.equals(idTokenPayload.getString("iss", null))) { if (debug) this.log.debug("the ID token issuer does not match"); return null; } // validate audience match final Object audValue = idTokenPayload.get("aud", ""); boolean audMatch = false; if (audValue instanceof JSONArray) { final JSONArray auds = (JSONArray) audValue; for (int n = auds.length() - 1; n >= 0; n--) { if (opDesc.getClientId().equals(auds.get(n))) { audMatch = true; break; } } } else { audMatch = opDesc.getClientId().equals(audValue); } if (!audMatch) { if (debug) this.log.debug("the ID token audience does not match"); return null; } // validate authorized party if ((audValue instanceof JSONArray) && idTokenPayload.has("azp")) { if (!opDesc.getClientId().equals(idTokenPayload.get("azp"))) { if (debug) this.log.debug("the ID token authorized party does not" + " match"); return null; } } // validate token expiration if (!idTokenPayload.has("exp") || (idTokenPayload.getLong("exp") * 1000L) <= System.currentTimeMillis()) { if (debug) this.log.debug("the ID token expired or no expiration time"); return null; } // validate signature if (!this.isSignatureValid(opDesc, idTokenHeader, idTokenParts[0] + '.' + idTokenParts[1], idTokenSignature)) { if (debug) this.log.debug("invalid signature"); return null; } if (debug) this.log.debug("signature validated successfully"); // the token is valid, proceed: // get username from the ID token JSONObject usernameClaimContainer = idTokenPayload; final String[] usernameClaimParts = opDesc.getUsernameClaimParts(); for (int i = 0; i < usernameClaimParts.length - 1; i++) { final Object v = usernameClaimContainer.opt(usernameClaimParts[i]); if ((v == null) || !(v instanceof JSONObject)) { if (debug) this.log.debug("the ID token does not contain the \"" + opDesc.getUsernameClaim() + "\" claim used as the username claim"); return null; } usernameClaimContainer = (JSONObject) v; } final String username = usernameClaimContainer.optString( usernameClaimParts[usernameClaimParts.length - 1], null); if (username == null) { if (debug) this.log.debug("the ID token does not contain the \"" + opDesc.getUsernameClaim() + "\" claim used as the username claim"); return null; } // authenticate the user in the realm if (debug) this.log.debug("authenticating user \"" + username + "\""); final Principal principal = this.context.getRealm().authenticate(username); if (principal == null) { if (debug) this.log.debug("failed to authenticate the user in the realm"); return null; } // save authentication info in the session session.setNote(Constants.SESS_USERNAME_NOTE, principal.getName()); session.setNote(SESS_OIDC_AUTH_NOTE, authorization); // save authorization in the session for the application session.getSession().setAttribute(AUTHORIZATION_ATT, authorization); // return the user descriptor return new AuthedUser(principal, username, null); } /** * Check if the JWT signature is valid. * * @param opDesc OP descriptor. * @param header Decoded JWT header. * @param data The JWT data (encoded header and payload). * @param signature The signature from the JWT to test. * * @return {@code true} if valid. * * @throws IOException If an I/O error happens loading necessary data. */ protected boolean isSignatureValid(final OPDescriptor opDesc, final JSONObject header, final String data, final byte[] signature) throws IOException { try { final String sigAlg = header.optString("alg"); switch (sigAlg) { case "RS256": final Signature sig = Signature.getInstance("SHA256withRSA"); sig.initVerify(this.ops.getOPConfiguration(opDesc.getIssuer()) .getJWKSet().getKey(header.getString("kid"))); sig.update(data.getBytes("ASCII")); return sig.verify(signature); case "HS256": if (opDesc.getClientSecret() == null) { this.log.warn("client secret required for HS256 signature" + " algorithm is not configured, reporting" + " signature invalid"); return false; } final Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(BASE64URL_DECODER.decode( opDesc.getClientSecret()), "HmacSHA256")); mac.update(data.getBytes("ASCII")); final byte[] genSig = mac.doFinal(); return Arrays.equals(genSig, signature); default: this.log.warn("unsupported token signature algorithm \"" + sigAlg + "\", skipping signature verification"); return true; } } catch (final NoSuchAlgorithmException | SignatureException | InvalidKeyException | UnsupportedEncodingException e) { throw new RuntimeException( "Platform lacks signature algorithm support.", e); } } /** * Call the OP's token endpoint and exchange the authorization code. * * @param opDesc OP descriptor. * @param authCode The authorization code received from the authentication * endpoint. * @param request The request. * * @return The token endpoint response. * * @throws IOException If an I/O error happens communicating with the * endpoint. */ protected TokenEndpointResponse callTokenEndpoint(final OPDescriptor opDesc, final String authCode, final Request request) throws IOException { final boolean debug = this.log.isDebugEnabled(); // get the OP configuration final OPConfiguration opConfig = this.ops.getOPConfiguration(opDesc.getIssuer()); final URL tokenEndpointURL = new URL(opConfig.getTokenEndpoint()); // build POST body final StringBuilder buf = new StringBuilder(256); buf.append("grant_type=authorization_code"); buf.append("&code=").append(URLEncoder.encode(authCode, UTF8.name())); buf.append("&redirect_uri=").append(URLEncoder.encode( this.getBaseURL(request) + Constants.FORM_ACTION, UTF8.name())); // configure connection final HttpURLConnection con = (HttpURLConnection) tokenEndpointURL.openConnection(); con.setConnectTimeout(this.httpConnectTimeout); con.setReadTimeout(this.httpReadTimeout); con.setDoOutput(true); con.addRequestProperty("Content-Type", "application/x-www-form-urlencoded"); con.addRequestProperty("Accept", "application/json"); con.setInstanceFollowRedirects(false); // configure authentication switch (opDesc.getTokenEndpointAuthMethod()) { case CLIENT_SECRET_BASIC: con.addRequestProperty("Authorization", "Basic " + BASE64_ENCODER.encodeToString( (opDesc.getClientId() + ":" + opDesc.getClientSecret()) .getBytes(UTF8))); break; case CLIENT_SECRET_POST: buf.append("&client_id=").append(URLEncoder.encode( opDesc.getClientId(), UTF8.name())); buf.append("&client_secret=").append(URLEncoder.encode( opDesc.getClientSecret(), UTF8.name())); break; default: // nothing } // finish POST body and log the call final String postBody = buf.toString(); if (debug) this.log.debug("calling token endpoint at " + tokenEndpointURL + " with: " + postBody); // send POST and read response JSONObject responseBody; try (final OutputStream out = con.getOutputStream()) { out.write(postBody.getBytes(UTF8.name())); out.flush(); try (final Reader in = new InputStreamReader( con.getInputStream(), UTF8)) { responseBody = new JSONObject(new JSONTokener(in)); } catch (final IOException e) { final InputStream errorStream = con.getErrorStream(); if (errorStream == null) throw e; try (final Reader in = new InputStreamReader(errorStream, UTF8)) { responseBody = new JSONObject(new JSONTokener(in)); } } } // create response object final TokenEndpointResponse response = new TokenEndpointResponse( con.getResponseCode(), con.getDate(), responseBody); // log the response if (debug) this.log.debug("received response: " + response.toString()); // return the response return response; } /** * Process the case when the session expired while waiting for the user * login input. If landing page is configured, tries to redirect to it. * Otherwise, sends back an request timeout error response. * * @param request The request. * @param response The response. * * @throws IOException If an I/O error happens communicating with the * client. */ protected void processExpiredSession(final Request request, final HttpServletResponse response) throws IOException { // redirect to the configured landing page, if any if (!this.redirectToLandingPage(request, response)) response.sendError(HttpServletResponse.SC_REQUEST_TIMEOUT, sm.getString("authenticator.sessionExpired")); } /** * Redirect to the configured landing page, if any. * * @param request The request. * @param response The response. * * @return {@code true} if successfully redirected, {@code false} if no * landing page is configured. * * @throws IOException If an I/O error happens communicating with the * client. */ protected boolean redirectToLandingPage(final Request request, final HttpServletResponse response) throws IOException { // do we have landing page configured? if (this.landingPage == null) return false; // construct landing page URI final String uri = request.getContextPath() + this.landingPage; // make it think the user originally requested the landing page final SavedRequest savedReq = new SavedRequest(); savedReq.setMethod("GET"); savedReq.setRequestURI(uri); savedReq.setDecodedRequestURI(uri); request.getSessionInternal(true).setNote( Constants.FORM_REQUEST_NOTE, savedReq); // send the redirect response.sendRedirect(response.encodeRedirectURL(uri)); // done, success return true; } /** * Get web-application base URL (either from the {@code hostBaseURI} * authenticator property or auto-detected from the request). * * @param request The request. * * @return Base URL. */ protected String getBaseURL(final Request request) { if (this.hostBaseURI != null) return this.hostBaseURI + request.getContextPath(); final StringBuilder baseURLBuf = new StringBuilder(64); baseURLBuf.append("https://").append(request.getServerName()); final int port = request.getServerPort(); if (port != 443) baseURLBuf.append(':').append(port); baseURLBuf.append(request.getContextPath()); return baseURLBuf.toString(); } @Override public void logout(final Request request) { final Session session = request.getSessionInternal(false); if (session != null) { session.removeNote(SESS_STATE_NOTE); session.removeNote(Constants.SESS_USERNAME_NOTE); session.removeNote(SESS_OIDC_AUTH_NOTE); session.removeNote(Constants.FORM_REQUEST_NOTE); session.getSession().removeAttribute(AUTHORIZATION_ATT); } super.logout(request); } }