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..3b1803aedf 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 @@ -24,12 +24,15 @@ import com.google.inject.Singleton; import java.net.URI; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Map; import java.util.Set; import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.auth.openid.conf.ConfigurationService; +import org.apache.guacamole.auth.openid.OpenIDAuthenticationSessionManager; 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; @@ -40,7 +43,11 @@ import org.apache.guacamole.net.auth.Credentials; import org.apache.guacamole.net.auth.credentials.CredentialsInfo; import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException; +import org.apache.guacamole.net.auth.IdentifierGenerator; import org.jose4j.jwt.JwtClaims; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Service that authenticates Guacamole users by processing OpenID tokens. @@ -48,11 +55,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 authentication and redirect. + * OpenID services upon successful implicit flow authentication. + * */ - public static final String TOKEN_PARAMETER_NAME = "id_token"; + 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 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 AUTH_SESSION_QUERY_PARAM = "state"; /** * Service for retrieving OpenID configuration information. @@ -60,6 +86,12 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Inject private ConfigurationService confService; + /** + * Manager of active OpenID authentication attempts. + */ + @Inject + private OpenIDAuthenticationSessionManager sessionManager; + /** * Service for validating and generating unique nonce values. */ @@ -78,6 +110,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 +137,57 @@ 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); + logger.debug("OpenID authentication with '{}' reponse type (ID: {}, Secret: {}, PKCE: {})", + confService.getResponseType(), + confService.getClientID(), + confService.getClientSecret(), + confService.isPKCERequired()); + + 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()) { + // Recover session + String identifier = getSessionIdentifier(credentials); + if (identifier != null) { + verifier = sessionManager.getVerifier(identifier); + } + } + 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); 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(), new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) })) ); @@ -121,13 +196,40 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) @Override public URI getLoginURI() throws GuacamoleException { - return UriBuilder.fromUri(confService.getAuthorizationEndpoint()) + 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("redirect_uri", confService.getRedirectURI()); + + if (confService.isImplicitFlow()) { + builder.queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)); + } + else { + if (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 + OpenIDAuthenticationSession session = new OpenIDAuthenticationSession(codeVerifier, + confService.getMaxPKCEVerifierValidity() * 60000L); + String identifier = IdentifierGenerator.generateIdentifier(); + sessionManager.defer(session, identifier); + + builder.queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256") + .queryParam(AUTH_SESSION_QUERY_PARAM, identifier); + } + } + + return builder.build(); } @Override @@ -156,7 +258,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..f533aa6e42 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.openid.OpenIDAuthenticationSessionManager; 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(OpenIDAuthenticationSessionManager.class); + 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..92fa014ac0 --- /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..5bc3b5c40f 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,64 @@ 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. @@ -250,6 +323,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 +339,15 @@ public class ConfigurationService { * guacamole.properties. * * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the authorization * endpoint property is missing. */ public URI getAuthorizationEndpoint() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_AUTHORIZATION_ENDPOINT); + URI authorization_endpoint = environment.getProperty(OPENID_AUTHORIZATION_ENDPOINT); + authorization_endpoint = authorization_endpoint == null ? confWellKnown.getAuthorizationEndpoint() : authorization_endpoint; + if (authorization_endpoint == null) { + throw new GuacamoleException("Property openid-authorization-endpoint or openid-well-known-endpoint is required"); + } + return authorization_endpoint; } /** @@ -316,7 +399,12 @@ public URI getRedirectURI() throws GuacamoleException { * is missing. */ public String getIssuer() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_ISSUER); + String issuer = environment.getProperty(OPENID_ISSUER); + issuer = issuer == null ? confWellKnown.getIssuer() : issuer; + if (issuer == null) { + throw new GuacamoleException("Property openid-issuer or openid-well-known-endpoint is required"); + } + return issuer; } /** @@ -334,7 +422,114 @@ public String getIssuer() throws GuacamoleException { * property is missing. */ public URI getJWKSEndpoint() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_JWKS_ENDPOINT); + URI jwks_uri = environment.getProperty(OPENID_JWKS_ENDPOINT); + jwks_uri = jwks_uri == null ? confWellKnown.getJWKSEndpoint() : jwks_uri; + 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 + * endpoint property is missing. + */ + public URI getTokenEndpoint() throws GuacamoleException { + URI token_endpoint = environment.getProperty(OPENID_TOKEN_ENDPOINT); + token_endpoint = token_endpoint == null ? confWellKnown.getTokenEndpoint() : token_endpoint; + 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); + } + + /** + * Returns true if the response type defines an implict flow + * + * @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 toen 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, or if the client ID + * property is missing. + */ + 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. By default will be 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 { + Boolean enabled = environment.getProperty(OPENID_PKCE_REQUIRED); + return enabled != null && enabled; + } + + /** + * 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..845771ab75 --- /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,67 @@ +/* + * 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..e74d4c1024 --- /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,227 @@ +/* + * 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.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 detected issuer + */ + private static String issuer = null; + + /** + * The detected authorization edpoint + */ + private static URI authorization_endpoint = null; + + /** + * The detected token edpoint + */ + private static URI token_endpoint = null; + + /** + * The detected jwks_uri + */ + private static URI jwks_uri = null; + + /** + * 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, or if the authorization + * endpoint property is missing. + */ + private URI getWellKnownEndpoint() throws GuacamoleException { + return environment.getProperty(OPENID_WELL_KNOWN_ENDPOINT); + } + + /** + * 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() { + 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() { + 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() { + 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() { + return jwks_uri; + } + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /* + * 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) { + return; + } + } + catch (Exception e) { + 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(); + 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(); + } + } + }; + + 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..36260aeaff 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,8 @@ package org.apache.guacamole.auth.openid.token; import com.google.inject.Inject; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -29,6 +31,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 +73,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,26 +107,36 @@ 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); - // Verify a nonce is present - String nonce = claims.getStringClaimValue("nonce"); - if (nonce != null) { - // Verify that we actually generated the nonce, and that it has not - // already been used - if (nonceService.isValid(nonce)) { - // nonce is valid, consider claims valid - return claims; + if (confService.isImplicitFlow()) { + // Verify a nonce is present + String nonce = claims.getStringClaimValue("nonce"); + if (nonce != null) { + // Verify that we actually generated the nonce, and that it has not + // already been used + if (nonceService.isValid(nonce)) { + // nonce is valid, consider claims valid + return claims; + } + else { + logger.info("Rejected OpenID token with invalid/old nonce."); + } } else { - logger.info("Rejected OpenID token with invalid/old nonce."); + logger.info("Rejected OpenID token without nonce."); } } else { - logger.info("Rejected OpenID token without nonce."); + return claims; } } // Log any failures to validate/parse the JWT @@ -131,6 +150,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 (Exception e) { + logger.info("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 +284,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..ef6b28fcc5 --- /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,106 @@ +/* + * 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 htpp 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..8257a8a3c6 --- /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 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 S56 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); + } + + /** + * Base64URL encoding without padding. + * + * @param bytes + * The bytes to be Base54 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); + } +}