diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java index 2b130b610a..fd9d87873c 100644 --- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java +++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java @@ -81,8 +81,10 @@ public AuthenticatedUser authenticateUser(AuthenticationProvider authenticationP // Authenticate user AuthenticatedUser user = userService.retrieveAuthenticatedUser(authenticationProvider, credentials); - if (user != null) + if (user != null) { + user.setOriginalUri(credentials.getRequestDetails().getRequestURI()); return user; + } // Otherwise, unauthorized throw new GuacamoleInvalidCredentialsException("Invalid login", CredentialsInfo.USERNAME_PASSWORD); diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/RemoteAuthenticatedUser.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/RemoteAuthenticatedUser.java index a936e4ecc0..626ea174ef 100644 --- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/RemoteAuthenticatedUser.java +++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/RemoteAuthenticatedUser.java @@ -51,6 +51,12 @@ public abstract class RemoteAuthenticatedUser implements AuthenticatedUser { */ private final Set effectiveGroups; + /** + * The URI that was originally used to the first call to the authentication + * providers authenticateUser method. + */ + private String originalUri; + /** * Creates a new RemoteAuthenticatedUser, deriving the associated remote * host from the given credentials. @@ -103,4 +109,14 @@ public void invalidate() { // Nothing to invalidate } + @Override + public void setOriginalUri(String originalUri) { + this.originalUri = originalUri; + } + + @Override + public String getOriginalUri() { + return this.originalUri; + } + } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSession.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSession.java new file mode 100644 index 0000000000..df2d170c3f --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSession.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.sso.session; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; +import org.apache.guacamole.net.auth.AuthenticationSession; +import org.apache.guacamole.net.auth.Credentials; + +/** + * Representation of an in-progress OpenID authentication attempt. + */ +public class SSOAuthenticationSession extends AuthenticationSession { + /** + * The key value used to store the redirection URI + */ + private static String REDIRECTION = "redirection"; + + /** + * THe key value of the redirection URI in the credential parameers + */ + private static String REQUEST_HREF = "href"; + + /** + * A Map of Arbitrary session data + */ + private final Map session; + + /** + * Creates a new AuthenticationSession representing an in-progress + * authentication attempt. + * + * @param session + * A Map of the session data to be stored + * + * @param expires + * The number of milliseconds that may elapse before this session must + * be considered invalid. + */ + public SSOAuthenticationSession(Map session, long expires) { + super(expires); + this.session = session; + } + + /** + * Creates a new AuthenticationSession representing an in-progress + * authentication attempt. + * + * @param expires + * The number of milliseconds that may elapse before this session must + * be considered invalid. + */ + public SSOAuthenticationSession(long expires) { + this(new ConcurrentHashMap<>(), expires); + } + + /** + * Returns the stored session data + * + * @return + * The session data, can be null + */ + public Map getSession() { + return session; + } + + /** + * Returns an Object stored in the session data + * + * @return + * The object in the session, can be null + */ + public Object get(String key) { + return session.get(key); + } + + /** + * Returns an Object stored in the session data + * + * @return + * The object in the session, can be null + */ + public void put(String key, Object value) { + session.put(key, value); + } + + /** + * Special case for redirection from credentials to + * simplify he authentication providers + * + * @return + * The redirection stored in teh session + */ + public String getRedirection() { + Object obj = session.get(REDIRECTION); + return obj == null ? null : obj.toString(); + } + + /** + * Special case for redirection from credentials to + * simplify he authentication providers + * + * @param credentials + * The credentials from which to extract the redirection. + */ + public void setRedirection(Credentials credentials) { + String redirection = credentials.getParameter(REQUEST_HREF); + put(REDIRECTION, redirection); + } +} + + diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSessionManager.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSessionManager.java new file mode 100644 index 0000000000..60116ddfe0 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/session/SSOAuthenticationSessionManager.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.sso.session; + +import java.util.Map; +import com.google.inject.Singleton; +import org.apache.guacamole.net.auth.AuthenticationSessionManager; + +/** + * Manager service that temporarily stores authentication attempts while + * the authentication flow is underway. + */ +@Singleton +public class SSOAuthenticationSessionManager + extends AuthenticationSessionManager { + + /** + * Returns the stored session data used with the identity provider + * + * @param identifier + * The unique string returned by the call to defer(). For convenience, + * this value may safely be null. + * + * @return + * The session data + */ + public SSOAuthenticationSession resume(String identifier) { + return super.resume(identifier); + } +} + diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java index dc559c6b72..f14c78a3ec 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java @@ -30,9 +30,12 @@ import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.auth.openid.conf.ConfigurationService; import org.apache.guacamole.auth.openid.token.TokenValidationService; +import org.apache.guacamole.auth.openid.util.PKCEUtil; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService; +import org.apache.guacamole.auth.sso.session.SSOAuthenticationSession; +import org.apache.guacamole.auth.sso.session.SSOAuthenticationSessionManager; import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser; import org.apache.guacamole.form.Field; import org.apache.guacamole.form.RedirectField; @@ -41,6 +44,8 @@ import org.apache.guacamole.net.auth.credentials.CredentialsInfo; import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException; import org.jose4j.jwt.JwtClaims; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Service that authenticates Guacamole users by processing OpenID tokens. @@ -48,11 +53,30 @@ @Singleton public class AuthenticationProviderService implements SSOAuthenticationProviderService { + /** + * Logger for this class. + */ + private final Logger logger = LoggerFactory.getLogger(AuthenticationProviderService.class); + + /** + * The standard HTTP parameter which will be included within the URL by all + * OpenID services upon successful implicit flow authentication. + * + */ + public static final String IMPLICIT_TOKEN_PARAMETER_NAME = "id_token"; + /** * The standard HTTP parameter which will be included within the URL by all - * OpenID services upon successful authentication and redirect. + * OpenID services upon successful code flow authentication. Used to recover + * the stored user state. + */ + public static final String CODE_TOKEN_PARAMETER_NAME = "code"; + + /** + * The name of the query parameter that identifies an active authentication + * session (in-progress OpenID authentication attempt). */ - public static final String TOKEN_PARAMETER_NAME = "id_token"; + public static final String AUTH_SESSION_QUERY_PARAM = "state"; /** * Service for retrieving OpenID configuration information. @@ -60,6 +84,12 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Inject private ConfigurationService confService; + /** + * Manager of active OpenID authentication attempts. + */ + @Inject + private SSOAuthenticationSessionManager sessionManager; + /** * Service for validating and generating unique nonce values. */ @@ -78,6 +108,25 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Inject private Provider authenticatedUserProvider; + /** + * Return the value of the session identifier associated with the given + * credentials, or null if no session identifier is found in the + * credentials. + * + * @param credentials + * The credentials from which to extract the session identifier. + * + * @return + * The session identifier associated with the given credentials, or + * null if no identifier is found. + */ + public static String getSessionIdentifier(Credentials credentials) { + + // Return the session identifier from the request params, if set, or + // null otherwise + return credentials != null ? credentials.getParameter(AUTH_SESSION_QUERY_PARAM) : null; + } + @Override public SSOAuthenticatedUser authenticateUser(Credentials credentials) throws GuacamoleException { @@ -86,33 +135,63 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) Set groups = null; Map tokens = Collections.emptyMap(); - // Validate OpenID token in request, if present, and derive username - String token = credentials.getParameter(TOKEN_PARAMETER_NAME); - if (token != null) { - JwtClaims claims = tokenService.validateToken(token); - if (claims != null) { - username = tokenService.processUsername(claims); - groups = tokenService.processGroups(claims); - tokens = tokenService.processAttributes(claims); + // Recover session + String identifier = getSessionIdentifier(credentials); + SSOAuthenticationSession session = sessionManager.resume(identifier); + + logger.debug("OpenID authentication with '{}' reponse type (ID: {}, Secret: {}, PKCE: {}, Redirection: {})", + confService.getResponseType(), + confService.getClientID(), + confService.getClientSecret(), + confService.isPKCERequired(), + credentials.getParameter("href")); + + if (confService.isImplicitFlow()) { + String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); + if (token != null) { + JwtClaims claims = tokenService.validateTokenOrCode(token, ""); + if (claims != null) { + username = tokenService.processUsername(claims); + groups = tokenService.processGroups(claims); + tokens = tokenService.processAttributes(claims); + } + } + } + else { + String verifier = null; + if (confService.isPKCERequired()) { + if (session != null) { + verifier = session.get("verifier").toString(); + } + } + String code = credentials.getParameter("code"); + if (code != null && (confService.isPKCERequired() == false || verifier != null)) { + JwtClaims claims = tokenService.validateTokenOrCode(code, verifier); + if (claims != null) { + username = tokenService.processUsername(claims); + groups = tokenService.processGroups(claims); + tokens = tokenService.processAttributes(claims); + } } } // If the username was successfully retrieved from the token, produce // authenticated user if (username != null) { - // Create corresponding authenticated user SSOAuthenticatedUser authenticatedUser = authenticatedUserProvider.get(); authenticatedUser.init(username, credentials, groups, tokens); + if (session != null) { + authenticatedUser.setOriginalUri(session.getRedirection()); + } return authenticatedUser; - } // Request OpenID token (will automatically redirect the user to the // OpenID authorization page via JavaScript) throw new GuacamoleInvalidCredentialsException("Invalid login.", new CredentialsInfo(Arrays.asList(new Field[] { - new RedirectField(TOKEN_PARAMETER_NAME, getLoginURI(), + new RedirectField(AUTH_SESSION_QUERY_PARAM, getLoginURI(credentials), new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) })) ); @@ -121,13 +200,44 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) @Override public URI getLoginURI() throws GuacamoleException { - return UriBuilder.fromUri(confService.getAuthorizationEndpoint()) + return getLoginURI(null); + } + + private URI getLoginURI(Credentials credentials) throws GuacamoleException { + UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) .queryParam("scope", confService.getScope()) - .queryParam("response_type", "id_token") + .queryParam("response_type", confService.getResponseType().toString()) .queryParam("client_id", confService.getClientID()) .queryParam("redirect_uri", confService.getRedirectURI()) - .queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)) - .build(); + .queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)); + + if (! confService.isImplicitFlow() && confService.isPKCERequired()) { + String codeVerifier = PKCEUtil.generateCodeVerifier(); + String codeChallenge; + + try { + codeChallenge = PKCEUtil.generateCodeChallenge(codeVerifier); + } + catch (Exception e) { + throw new GuacamoleException("Unable to compute PKCE challenge", e); + } + + // Store verifier for authenticateUser + SSOAuthenticationSession session = new SSOAuthenticationSession( + confService.getMaxPKCEVerifierValidity() * 60000L); + session.put("verifier", codeVerifier); + if (credentials != null) { + session.setRedirection(credentials); + logger.debug("Redirection set : {}", session.getRedirection()); + } + String identifier = sessionManager.defer(session); + + builder.queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256") + .queryParam(AUTH_SESSION_QUERY_PARAM, identifier); + } + + return builder.build(); } @Override @@ -156,7 +266,7 @@ public URI getLogoutURI(String idToken) throws GuacamoleException { @Override public void shutdown() { - // Nothing to clean up + sessionManager.shutdown(); } } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java index 6dc45f7f97..f24d849945 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java @@ -21,8 +21,11 @@ import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.Singleton; import org.apache.guacamole.auth.openid.conf.ConfigurationService; import org.apache.guacamole.auth.openid.conf.OpenIDEnvironment; +import org.apache.guacamole.auth.openid.conf.OpenIDWellKnown; +import org.apache.guacamole.auth.sso.session.SSOAuthenticationSessionManager; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.auth.openid.token.TokenValidationService; import org.apache.guacamole.environment.Environment; @@ -42,7 +45,8 @@ protected void configure() { bind(ConfigurationService.class); bind(NonceService.class).in(Scopes.SINGLETON); bind(TokenValidationService.class); - + bind(SSOAuthenticationSessionManager.class).in(Scopes.SINGLETON); + bind(Environment.class).toInstance(environment); } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSession.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSession.java new file mode 100644 index 0000000000..4246caeb08 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSession.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid; + +import org.apache.guacamole.net.auth.AuthenticationSession; + +/** + * Representation of an in-progress OpenID authentication attempt. + */ +public class OpenIDAuthenticationSession extends AuthenticationSession { + + /** + * The PKCE challenge verifier. + */ + private final String verifier; + + /** + * Creates a new AuthenticationSession representing an in-progress OpenID + * authentication attempt. + * + * @param expires + * The number of milliseconds that may elapse before this session must + * be considered invalid. + */ + public OpenIDAuthenticationSession(String verifier, long expires) { + super(expires); + this.verifier = verifier; + } + + /** + * Returns the stored PKCE verifier + * + * @return + * The PKCE verifier + */ + public String getVerifier() { + return verifier; + } +} + diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSessionManager.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSessionManager.java new file mode 100644 index 0000000000..8a2371259e --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSessionManager.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid; + +import com.google.inject.Singleton; +import org.apache.guacamole.net.auth.AuthenticationSessionManager; + +/** + * Manager service that temporarily stores OpenID authentication attempts while + * the authentication flow is underway. + */ +@Singleton +public class OpenIDAuthenticationSessionManager + extends AuthenticationSessionManager { + + /** + * Returns the stored PKCE verifier used with the identity provider + * + * @param identifier + * The unique string returned by the call to defer(). For convenience, + * this value may safely be null. + * + * @return + * The PKCE verifier used with the identity provider + */ + public String getVerifier(String identifier) { + OpenIDAuthenticationSession session = resume(identifier); + if (session != null) + return session.getVerifier(); + return null; + } +} + diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java index 29aee31396..f8dfe14c23 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java @@ -24,8 +24,12 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import org.apache.guacamole.auth.openid.conf.OpenIDResponseType; +import org.apache.guacamole.auth.openid.conf.OpenIDWellKnown; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.properties.EnumGuacamoleProperty; import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; @@ -36,6 +40,11 @@ */ public class ConfigurationService { + /** + * The default OICD reponse type + */ + private static final OpenIDResponseType DEFAULT_RESPONSE_TYPE = OpenIDResponseType.ID_TOKEN; + /** * The default claim type to use to retrieve an authenticated user's * username. @@ -76,6 +85,12 @@ public class ConfigurationService { */ private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; + /** + * The default maximum amount of time that a pkce verifier generated by the + * Guacamole server should remain valid, in minutes. + */ + private static final int DEFAULT_MAX_PKCE_VERIFIER_VALIDITY = 10; + /** * The authorization endpoint (URI) of the OpenID service. */ @@ -110,6 +125,67 @@ public class ConfigurationService { }; + /** + * The token endpoint (URI) of the OIDC service. + */ + private static final URIGuacamoleProperty OPENID_TOKEN_ENDPOINT = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-token-endpoint"; } + + }; + + /** + * The reponse type of the OpenID service. + */ + private static final EnumGuacamoleProperty OPENID_RESPONSE_TYPE = + new EnumGuacamoleProperty(OpenIDResponseType.class) { + + @Override + public String getName() { return "openid-response-type"; } + + }; + + /** + * OIDC client secret which should be submitted to the OIDC service when + * validating tokens with code flow + */ + private static final StringGuacamoleProperty OPENID_CLIENT_SECRET = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-client-secret"; } + + }; + + /** + * True if "Proof Key for Code Exchange" (PKCE) must be used. + */ + private static final BooleanGuacamoleProperty OPENID_PKCE_REQUIRED = + new BooleanGuacamoleProperty() { + + @Override + public String getName() { return "openid-pkce-required"; } + + }; + + /** + * The maximum amount of time that a PKCE verifier generated by the + * Guacamole server should remain valid, in minutes. As each OpenID request + * requiring PKCE has a unique verifier, this imposes an upper limit on the + * amount of time any particular OpenID request can result in successful + * authentication within Guacamole. By default, each generated PKCE verifier + * expires after 10 minutes. + */ + private static final IntegerGuacamoleProperty OPENID_MAX_PKCE_VERIFIER_VALIDITY = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "openid-max-pkce-verifier-validity"; } + + }; + /** * The claim type which contains the authenticated user's username within * any valid JWT. @@ -140,9 +216,10 @@ public class ConfigurationService { */ private static final StringGuacamoleProperty OPENID_ATTRIBUTES_CLAIM_TYPE = new StringGuacamoleProperty() { - @Override - public String getName() { return "openid-attributes-claim-type"; } - }; + @Override + public String getName() { return "openid-attributes-claim-type"; } + + }; /** * The space-separated list of OpenID scopes to request. @@ -250,6 +327,12 @@ public class ConfigurationService { */ @Inject private Environment environment; + + /** + * Service for retrieving OpenID well-known data. + */ + @Inject + private OpenIDWellKnown confWellKnown; /** * Returns the authorization endpoint (URI) of the OpenID service as @@ -260,11 +343,15 @@ public class ConfigurationService { * guacamole.properties. * * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the authorization - * endpoint property is missing. + * If guacamole.properties cannot be parsed, or if authorization and + * well-known endpoints properties are missing. */ public URI getAuthorizationEndpoint() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_AUTHORIZATION_ENDPOINT); + URI authorization_endpoint = environment.getProperty(OPENID_AUTHORIZATION_ENDPOINT, confWellKnown.getAuthorizationEndpoint()); + if (authorization_endpoint == null) { + throw new GuacamoleException("Property openid-authorization-endpoint or openid-well-known-endpoint is required"); + } + return authorization_endpoint; } /** @@ -312,11 +399,15 @@ public URI getRedirectURI() throws GuacamoleException { * guacamole.properties. * * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the issuer property - * is missing. + * If guacamole.properties cannot be parsed, or if issuer and + * well-known endpoints properties are missing. */ public String getIssuer() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_ISSUER); + String issuer = environment.getProperty(OPENID_ISSUER, confWellKnown.getIssuer()); + if (issuer == null) { + throw new GuacamoleException("Property openid-issuer or openid-well-known-endpoint is required"); + } + return issuer; } /** @@ -330,11 +421,114 @@ public String getIssuer() throws GuacamoleException { * guacamole.properties. * * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the JWKS endpoint - * property is missing. + * If guacamole.properties cannot be parsed, or if JWKS and + * well-known endpoints properties are missing. */ public URI getJWKSEndpoint() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_JWKS_ENDPOINT); + URI jwks_uri = environment.getProperty(OPENID_JWKS_ENDPOINT, confWellKnown.getJWKSEndpoint()); + if (jwks_uri == null) { + throw new GuacamoleException("Property openid-jwks-endpoint or openid-well-known-endpoint is required"); + } + return jwks_uri; + } + + /** + * Returns the token endpoint (URI) of the OIDC service as + * configured with guacamole.properties. + * + * @return + * The token endpoint of the OIDC service, as configured with + * guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed, or if the token and + * well-known endpoints properties are missing. + */ + public URI getTokenEndpoint() throws GuacamoleException { + URI token_endpoint = environment.getProperty(OPENID_TOKEN_ENDPOINT, confWellKnown.getTokenEndpoint()); + if (token_endpoint == null) { + throw new GuacamoleException("Property openid-token-endpoint or openid-well-known-endpoint is required"); + } + return token_endpoint; + } + + /** + * Returns the reponse type of the OpenID service as configured with guacamole.properties. + * + * @return + * The reponse type of the OpenID service, as configured with guacamole.properties. Can + * be either 'id_token', 'token' or 'code'. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public OpenIDResponseType getResponseType() throws GuacamoleException { + return environment.getProperty(OPENID_RESPONSE_TYPE, DEFAULT_RESPONSE_TYPE); + } + + /** + * True if implict flow should be used, otherwise false as defined in the guacamole.properties + * + * @return + * The whether implicit flow is used or not, as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public boolean isImplicitFlow() throws GuacamoleException { + OpenIDResponseType response_type = environment.getProperty(OPENID_RESPONSE_TYPE, DEFAULT_RESPONSE_TYPE); + return response_type != OpenIDResponseType.CODE; + } + + /** + * Returns the OIDC client secret used for token validation, as configured + * with guacamole.properties. This value is typically provided by the OIDC + * service when OIDC credentials are generated for your application, and + * may be null. + * + * @return + * The client secret to use when communicating with the OIDC service, + * as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public String getClientSecret() throws GuacamoleException { + return environment.getProperty(OPENID_CLIENT_SECRET); + } + + /** + * Returns a boolean value of whether "Proof Key for Code Exchange (PKCE)" + * will be used, as configured with guacamole.properties. The choice of + * whether to use PKCE is up to you, but the OIDC service must support + * it. True if PKCE should be used, otherwise false. + * + * @return + * The whether to use PKCE or not, as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public boolean isPKCERequired() throws GuacamoleException { + return environment.getProperty(OPENID_PKCE_REQUIRED, false); + } + + /** + * Returns the maximum amount of time that a PKCE verifier generated by the + * Guacamole server should remain valid, in minutes. As each OpenID request + * requiring PKCE has a unique verifier, this imposes an upper limit on the + * amount of time any particular OpenID request can result in successful + * authentication within Guacamole. By default, this will be 10. + * + * @return + * The maximum amount of time that a PKCE verifier generated by the + * Guacamole server should remain valid, in minutes. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public int getMaxPKCEVerifierValidity() throws GuacamoleException { + return environment.getProperty(OPENID_MAX_PKCE_VERIFIER_VALIDITY, DEFAULT_MAX_PKCE_VERIFIER_VALIDITY); } /** diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDResponseType.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDResponseType.java new file mode 100644 index 0000000000..5104fe01c8 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDResponseType.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid.conf; + +import org.apache.guacamole.properties.EnumGuacamoleProperty.PropertyValue; + +/** + * This enum represents the valid OIDC reponse types that can be used + */ + public enum OpenIDResponseType { + /** + * Response type "id_token" used for implciit flow as specified in the OpenID standard + */ + @PropertyValue("id_token") + ID_TOKEN("id_token"), + + /** + * Response type "token" used for implicit flow by certain Identity Providers, notably + * AWS Cognito. This corresponds to the official OIDC response_type "id_token token" + * that returns both the "id_token" and "access_token" parameters + */ + @PropertyValue("token") + TOKEN("token"), + + /** + * Response type "code" used for code flow authentication + */ + @PropertyValue("code") + CODE("code"); + + /** + * The string value of the response type used + */ + public final String STRING_VALUE; + + /** + * Initializes the response type such that it is associated with the + * given string value. + * + * @param value + * The string value that will be associated with the enum value. + */ + private OpenIDResponseType(String value) { + this.STRING_VALUE = value; + } + + @Override + public String toString() { + return STRING_VALUE; + } +} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDWellKnown.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDWellKnown.java new file mode 100644 index 0000000000..f86a03f801 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDWellKnown.java @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid.conf; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.Map; +import javax.ws.rs.core.UriBuilder; +import org.apache.guacamole.auth.openid.util.JsonUrlReader; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.URIGuacamoleProperty; +import org.jose4j.json.JsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class for retrieving well-known endpoint data. + */ +@Singleton +public class OpenIDWellKnown { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenIDWellKnown.class); + + + /** + * The number of attempts to the well-known endpoint to get the values before giving up + */ + private static final int MAX_ATTEMPTS = 24; + + /** + * The delay between each attempt to well-known endpoint in seconds + */ + private static final long DELAY_SECONDS = 5; + + /** + * The getters of this class, except getWellKnownEndpoint need to wait for the + * class to be fully initialized before returning. When this latch is zero the + * class is ready. + */ + private final CountDownLatch initializedLatch = new CountDownLatch(1); + + /** + * The detected issuer + */ + private String issuer = null; + + /** + * The detected authorization endpoint + */ + private URI authorization_endpoint = null; + + /** + * The detected token endpoint + */ + private URI token_endpoint = null; + + /** + * The detected jwks_uri + */ + private URI jwks_uri = null; + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * Empty constructor of the class to populate data recovered from a OIDC + * well-known URL. The class will be populated on injection by Guice + */ + public OpenIDWellKnown() { } + + /** + * The well-known endpoint (URI) of the OIDC service. + */ + private static final URIGuacamoleProperty OPENID_WELL_KNOWN_ENDPOINT = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-well-known-endpoint"; } + }; + + /** + * Returns the well-known endpoint (URI) of the OIDC service as + * configured with guacamole.properties. + * + * @return + * The well-known endpoint of the OIDC service, as configured with + * guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed + */ + private URI getWellKnownEndpoint() throws GuacamoleException { + return environment.getProperty(OPENID_WELL_KNOWN_ENDPOINT); + } + + /** + * A function that waits for the latch to count down to zero before continuing + */ + private void awaitInitialization() { + try { + initializedLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Initialization interrupted", e); + } + } + + /** + * Returns the issuer to expect for all received ID tokens, as configured + * from the well-known endpoint. + * + * @return + * The issuer to expect for all received ID tokens, as returned by the + * well-known endpoint. + */ + public String getIssuer() { + awaitInitialization(); + return issuer; + } + + /** + * Returns the authorization endpoint (URI) of the OpenID service as + * configured from the well-known endpoint. + * + * @return + * The authorization endpoint of the OpenID service, as returned by the + * well-known endpoint. + */ + public URI getAuthorizationEndpoint() { + awaitInitialization(); + return authorization_endpoint; + } + + /** + * Returns the token endpoint (URI) of the OpenID service as + * configured from the well-known endpoint. + * + * @return + * The token endpoint of the OpenID service, as returned by the + * well-known endpoint. + */ + public URI getTokenEndpoint() { + awaitInitialization(); + return token_endpoint; + } + + /** + * Returns the endpoint (URI) of the JWKS service which defines how + * received ID tokens (JWTs) shall be validated, as configured from + * the well-known endpoint. + * + * @return + * The endpoint (URI) of the JWKS service which defines how received ID + * tokens (JWTs) shall be validated, as configured from the + * well-known endpoint. + */ + public URI getJWKSEndpoint() { + awaitInitialization(); + return jwks_uri; + } + + /** + * On injection, when the environment is non null, populates the OpenIDWellKnown + * class by reading the json from an OIDC well-known endpoint and saves these values + * for later use. Use Guice to ensure environment exists before initializing. + */ + @Inject + public void init() { + // Fast return if there is no well-known endpoint or its unreadable + try { + if (getWellKnownEndpoint() == null) { + initializedLatch.countDown(); + return; + } + } + catch (GuacamoleException e) { + initializedLatch.countDown(); + return; + } + + // Call to well-known endpoint might fail, so allow several tries before giving up + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + Runnable task = new Runnable() { + int attempts = 0; + + @Override + public void run() { + attempts++; + + try { + Map json = JsonUrlReader.fetch("GET", getWellKnownEndpoint(), ""); + issuer = (String) json.get("issuer"); + authorization_endpoint = UriBuilder.fromUri((String) json.get("authorization_endpoint")).build(); + token_endpoint = UriBuilder.fromUri((String) json.get("token_endpoint")).build(); + jwks_uri = UriBuilder.fromUri((String) json.get("jwks_uri")).build(); + + logger.info("OIDC well-known\n" + + " issuer : {}\n" + + " authorization_endpoint : {}\n" + + " token_endpoint : {}\n" + + " jwks_uri : {}\n", + issuer, authorization_endpoint, token_endpoint, jwks_uri); + + scheduler.shutdown(); + initializedLatch.countDown(); + return; + } + catch (Exception e) { + logger.debug("Rejecting well-known endpoint : {}", e.getMessage()); + } + + if (attempts >= MAX_ATTEMPTS) { + logger.info("Timeout on well-known on endpoint"); + scheduler.shutdown(); + initializedLatch.countDown(); + } + } + }; + + scheduler.scheduleAtFixedRate(task, 0, DELAY_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java index 35a5158378..da9dab6953 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java @@ -20,6 +20,9 @@ package org.apache.guacamole.auth.openid.token; import com.google.inject.Inject; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -29,6 +32,7 @@ import java.util.Set; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.openid.conf.ConfigurationService; +import org.apache.guacamole.auth.openid.util.JsonUrlReader; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.token.TokenName; import org.jose4j.jwk.HttpsJwks; @@ -70,20 +74,26 @@ public class TokenValidationService { private NonceService nonceService; /** - * Validates the given ID token, returning the JwtClaims contained therein. - * If the ID token is invalid, null is returned. + * Validates the given ID token, using implicit flow. Also validates codes, + * exchanging them for id_tokens before validation. If the id_token or + * code is invalid, null is returned, otherwise the JwtClaims in the + * id_token are returned. * * @param token - * The ID token to validate. + * The ID token to validate if implicit flow or the code to exchange for + * an id_token and then validate. + * + * @param verifier + * A PKCE verifier or null if not used. Only used with code flow * * @return - * The JWT claims contained within the given ID token if it passes tests, - * or null if the token is not valid. + * The JWT claims contained within the id_token if it passes tests, + * or null if the id_token is not valid. * * @throws GuacamoleException * If guacamole.properties could not be parsed. */ - public JwtClaims validateToken(String token) throws GuacamoleException { + public JwtClaims validateTokenOrCode(String token, String verifier) throws GuacamoleException { // Validating the token requires a JWKS key resolver HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); @@ -98,7 +108,12 @@ public JwtClaims validateToken(String token) throws GuacamoleException { .setExpectedAudience(confService.getClientID()) .setVerificationKeyResolver(resolver) .build(); - + + /* Exchange code → token */ + if (!confService.isImplicitFlow()) { + token = exchangeCode(token, verifier); + } + try { // Validate JWT JwtClaims claims = jwtConsumer.processToClaims(token); @@ -131,6 +146,73 @@ public JwtClaims validateToken(String token) throws GuacamoleException { return null; } + /** + * URLEncodes a key/value pair + * + * @param key + * The key to encode + * + * @param value + * The value to encode + * + * @return + * The urlencoded kay/value pair + */ + private String urlencode(String key, String value) { + StringBuilder builder = new StringBuilder(); + return builder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append("=") + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)) + .toString(); + } + + /** + * Exchanges the authorization code for tokens. + * + * @param code + * The authorization code received from the IdP. + * + * @param codeVerifier + * The PKCE verifier (or null if PKCE is disabled). + * + * @return + * The token string returned. + * + * @throws GuacamoleException + * If a valid token is not returned. + */ + private String exchangeCode(String code, String verifier) throws GuacamoleException { + + try { + StringBuilder bodyBuilder = new StringBuilder(); + bodyBuilder.append(urlencode("grant_type", "authorization_code")).append("&"); + bodyBuilder.append(urlencode("code", code)).append("&"); + bodyBuilder.append(urlencode("redirect_uri", confService.getRedirectURI().toString())).append("&"); + bodyBuilder.append(urlencode("scope", confService.getScope())).append("&"); + bodyBuilder.append(urlencode("client_id", confService.getClientID())); + + String clientSecret = confService.getClientSecret(); + if (clientSecret != null && !clientSecret.trim().isEmpty()) { + bodyBuilder.append("&").append(urlencode("client_secret", clientSecret)); + } + + if (confService.isPKCERequired()) { + bodyBuilder.append("&").append(urlencode("code_verifier", verifier)); + } + + Map json = + JsonUrlReader.fetch("POST", confService.getTokenEndpoint(), + bodyBuilder.toString()); + + return (String) json.get("id_token"); + + } + catch (IOException e) { + logger.error("Rejected invalid OpenID code exchange: {}", e.getMessage(), e); + } + return null; + } + /** * Parses the given JwtClaims, returning the username contained * therein, as defined by the username claim type given in @@ -198,7 +280,7 @@ public Set processGroups(JwtClaims claims) throws GuacamoleException { List oidcGroups = claims.getStringListClaimValue(groupsClaim); if (oidcGroups != null && !oidcGroups.isEmpty()) return Collections.unmodifiableSet(new HashSet<>(oidcGroups)); - } + } catch (MalformedClaimException e) { logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/JsonUrlReader.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/JsonUrlReader.java new file mode 100644 index 0000000000..8d8aed6bc3 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/JsonUrlReader.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid.util; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.jose4j.json.JsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/* + * Utility class to open a http connection to a URL, send a body + * and receive a response in the form of a parsed JSON + */ + +public final class JsonUrlReader { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(JsonUrlReader.class); + + /** + * Class to GET and POST to a URL and read the returned JSON. This class should + * not be instantiated. + */ + private JsonUrlReader() {} + + /** + * Method to POST or GET to a URL and recover the JSON in the form of a Map + * + * @param String method + * The http method to use. Should be "GET", "POST" or "PATCH". + * + * @param URI uri + * A URI value giving the address where to recover the JSON + * + * @param String body + * A pre-encoded body string to be sent to the address if the method is + * "POST" or "PATCH". Ignored if the method is "GET". + * + * @return + * A Map containing the decoded json values. + */ + public static Map fetch(String method, URI uri, String body) throws IOException { + if (uri == null || uri.toString().isEmpty()) { + throw new IOException("JsonUrlReader: Missing URL"); + } + + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(uri); + if (method != "GET") { + // FIXME: If this function is ever used to post json bodies this header + // will need to be configurable + requestBuilder.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .method(method, HttpRequest.BodyPublishers.ofString(body == null ? "" : body, + StandardCharsets.UTF_8)); + } + else { + requestBuilder.GET(); + } + + // Asynchronous, non-blocking send, so that tomcat servlets are not blocked by outbound connection + CompletableFuture> future = client.sendAsync(requestBuilder.build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + HttpResponse response = future.join(); + int status = response.statusCode(); + logger.debug("Response body: {}", response.body()); + Map json = JsonUtil.parseJson(response.body()); + + if (status < 200 || status >= 300) { + throw new IOException("(status: " + status + "): " + json.toString()); + } + + return json; + } + catch (Exception e) { + throw new IOException("JsonUrlReader error: " + e.getMessage()); + } + } +} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/PKCEUtil.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/PKCEUtil.java new file mode 100644 index 0000000000..5ce56e9834 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/PKCEUtil.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.guacamole.auth.openid.util; + +import java.security.MessageDigest; +import java.security.SecureRandom; + +/** + * Utility class for generating PKCE parameters. + * + * Supports: + * - code_verifier (random Base64URL) + * - code_challenge (S256) + */ +public final class PKCEUtil { + /** + * Get the verifier data from a cryptographically secure random source + */ + private static final SecureRandom RANDOM = new SecureRandom(); + + /* + * Class to create PKCE challenges and verifiers. This class should not be instantiated + */ + private PKCEUtil() {} + + /** + * Generates a high-entropy PKCE code_verifier. + * + * @return + * A 256bit or 64 byte random Base64 URL encode string + */ + public static String generateCodeVerifier() { + byte[] bytes = new byte[64]; + RANDOM.nextBytes(bytes); + return base64Url(bytes); + } + + /** + * Computes the PKCE code_challenge = BASE64URL(SHA256(code_verifier)). + * + * @param String verifier + * A string containing the S256 verifier calculated bu generateCodeVerifier + * + * @return + * The generated S256 code challenge used for the PKCE request encoded + * in Base64 URL format. + */ + public static String generateCodeChallenge(String verifier) throws Exception { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + byte[] hash = sha.digest(verifier.getBytes("US-ASCII")); + return base64Url(hash); + } + + /** + * A method that Base64URL-encodes a provided byte-array without padding. + * + * @param bytes + * The bytes to be Base64 URL encoded + * + * @return + * The Base64 URL encoded string value corresponding to the bytes + */ + public static String base64Url(byte[] bytes) { + return java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes); + } +} diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/RequestDetails.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/RequestDetails.java index eecebbf512..54c8378ba8 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/RequestDetails.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/RequestDetails.java @@ -78,6 +78,11 @@ public class RequestDetails { */ private final List cookies; + /** + * The request URI of the associated request + */ + private final String requestURI; + /** * Returns an unmodifiable Map of all HTTP headers within the given request. * If there are no such headers, the returned Map will be empty. As there @@ -193,6 +198,7 @@ public RequestDetails(HttpServletRequest request) { this.parameters = getParameters(request); this.remoteAddress = request.getRemoteAddr(); this.remoteHostname = request.getRemoteHost(); + this.requestURI = request.getRequestURI(); this.session = request.getSession(false); } @@ -209,6 +215,7 @@ public RequestDetails(RequestDetails requestDetails) { this.parameters = requestDetails.getParameters(); this.remoteAddress = requestDetails.getRemoteAddress(); this.remoteHostname = requestDetails.getRemoteHostname(); + this.requestURI = requestDetails.getRequestURI(); this.session = requestDetails.getSession(); } @@ -412,4 +419,14 @@ public String getRemoteHostname() { return remoteHostname; } + /** + * Returns the request URI used by the client that sent the associated request + * + * @return + * The original request URI + */ + public String getRequestURI() { + return requestURI; + } + } diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AbstractAuthenticatedUser.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AbstractAuthenticatedUser.java index 10107c2f5f..51a4e29a57 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AbstractAuthenticatedUser.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AbstractAuthenticatedUser.java @@ -31,6 +31,12 @@ public abstract class AbstractAuthenticatedUser extends AbstractIdentifiable implements AuthenticatedUser { + /** + * The URI that was originally used to the first call to the authentication + * providers authenticateUser method. + */ + private String originalUri; + /** * Creates a new AbstractAuthenticatedUser that considers usernames to be * case-sensitive or case-insensitive based on the provided case sensitivity @@ -80,4 +86,14 @@ public void invalidate() { // Nothing to invalidate } + @Override + public void setOriginalUri(String originalUri) { + this.originalUri = originalUri; + } + + @Override + public String getOriginalUri() { + return this.originalUri; + } + } diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticatedUser.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticatedUser.java index a799937e85..6c3c629fc1 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticatedUser.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticatedUser.java @@ -75,4 +75,14 @@ public interface AuthenticatedUser extends Identifiable { */ void invalidate(); + /** + * A function to set the originalUri value + */ + public void setOriginalUri(String originalUri); + + /** + * A function to get the originalUri value + */ + public String getOriginalUri(); + } diff --git a/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js b/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js index af5a9ab6c3..a11203d71c 100644 --- a/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js +++ b/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js @@ -72,6 +72,7 @@ angular.module('auth').factory('authenticationService', ['$injector', var $rootScope = $injector.get('$rootScope'); var localStorageService = $injector.get('localStorageService'); var requestService = $injector.get('requestService'); + var $window = $injector.get('$window'); var service = {}; @@ -203,6 +204,10 @@ angular.module('auth').factory('authenticationService', ['$injector', // the stack when fed to $.param(). requestParams = _.omitBy(requestParams, (value, key) => key.startsWith('$')); + // Add a parameter "href" so that java authentication provider know + // where we came from + requestParams.href = $window.location.href; + return requestService({ method: 'POST', url: 'api/tokens', @@ -230,6 +235,10 @@ angular.module('auth').factory('authenticationService', ['$injector', setAuthenticationResult(new AuthenticationResult(data)); $rootScope.$broadcast('guacLogin', data.authToken); + // If a redirection is requested, redirect after authentication + if (data.redirection) { + $window.location.href = data.redirection; + } } // Update cached authentication result, even if the token remains diff --git a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java index 50ee2a13dd..d0c4a662a8 100644 --- a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java +++ b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java @@ -66,6 +66,11 @@ public class GuacamoleSession { */ private final ListenerService listenerService; + /** + * If the session has just authenticated, allow a redirection to be applied + */ + private String redirection; + /** * The last time this session was accessed. */ @@ -96,6 +101,7 @@ public GuacamoleSession(ListenerService listenerService, this.listenerService = listenerService; this.authenticatedUser = authenticatedUser; this.userContexts = userContexts; + this.redirection = null; } /** @@ -123,6 +129,32 @@ public void setAuthenticatedUser(AuthenticatedUser authenticatedUser) { this.authenticatedUser = authenticatedUser; } + /** + * Returns a redirection path for newly authenticated users, allowing + * redirection to where they left off when an authentication was + * requested. + * + * @return + * A String with the redirection path + */ + public String getRedirection() { + this.access(); + return this.redirection; + } + + /** + * Returns a redirection path for newly authenticated users, allowing + * redirection to where they left off when an authentication was + * requested. + * + * @return + * A String with the redirection path + */ + public void setRedirection(String redirection) { + this.access(); + this.redirection = redirection; + } + /** * Returns a list of all UserContexts associated with this session. Each * AuthenticationProvider currently loaded by Guacamole may provide its own diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/APIAuthenticationResult.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/APIAuthenticationResult.java index 2f50823424..92683f977e 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/APIAuthenticationResult.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/APIAuthenticationResult.java @@ -52,6 +52,11 @@ public class APIAuthenticationResult { */ private final List availableDataSources; + /** + * If the session has just authenticated, allow a redirection to be applied + */ + private final String redirection; + /** * Returns the unique authentication token which identifies the current * session. @@ -98,6 +103,18 @@ public List getAvailableDataSources() { return availableDataSources; } + /** + * Returns a redirection path for newly authenticated users, allowing + * redirection to where they left off when an authentication was + * requested. + * + * @return + * A String with the redirection path + */ + public String getRedirection() { + return redirection; + } + /** * Create a new APIAuthenticationResult object containing the given data. * @@ -115,13 +132,18 @@ public List getAvailableDataSources() { * @param availableDataSources * The unique identifier of all AuthenticationProviders to which the * user now has access. + * + * @param redirection + * A redirection proposed for the newly created sessions */ public APIAuthenticationResult(String authToken, String username, - String dataSource, List availableDataSources) { + String dataSource, List availableDataSources, + String redirection) { this.authToken = authToken; this.username = username; this.dataSource = dataSource; this.availableDataSources = Collections.unmodifiableList(availableDataSources); + this.redirection = redirection; } } diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java index 7037bb7894..86e4be1115 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java @@ -401,7 +401,13 @@ public String authenticate(Credentials credentials, String token) // If no existing session, generate a new token/session pair else { authToken = authTokenGenerator.getToken(); - tokenSessionMap.put(authToken, new GuacamoleSession(listenerService, authenticatedUser, userContexts)); + GuacamoleSession newSession = new GuacamoleSession(listenerService, authenticatedUser, userContexts); + String redirection = authenticatedUser.getOriginalUri(); + if (redirection != null && ! redirection.isEmpty()) { + logger.info(" ##### {}", redirection); + newSession.setRedirection(redirection); + } + tokenSessionMap.put(authToken, newSession); } // Report authentication success diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java index 427af7f399..5e4d00281f 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java @@ -190,7 +190,8 @@ public APIAuthenticationResult createToken(@FormParam("username") String usernam token, authenticatedUser.getIdentifier(), authenticatedUser.getAuthenticationProvider().getIdentifier(), - authProviderIdentifiers + authProviderIdentifiers, + session.getRedirection() ); }