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 dc559c6b7..7c48e0f22 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,11 +24,14 @@ import com.google.inject.Provider; 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.OpenIDRedirectResource; import org.apache.guacamole.auth.openid.token.TokenValidationService; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.sso.NonceService; @@ -50,9 +53,28 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS /** * 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"; + + /** + * The REST API used for a local rediect and callback for the IdP to + * return a code and access stored PKCE verifiers + */ + public static final String CODE_REDIRECT_API = "/api/ext/openid/redirect"; /** * Service for retrieving OpenID configuration information. @@ -60,6 +82,12 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Inject private ConfigurationService confService; + /** + * Manager of active SAML authentication attempts. + */ + @Inject + private OpenIDAuthenticationSessionManager sessionManager; + /** * Service for validating and generating unique nonce values. */ @@ -81,15 +109,24 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Override public SSOAuthenticatedUser authenticateUser(Credentials credentials) throws GuacamoleException { + if (isImplicit()) { + return authenticateUserImplicit(credentials); + } else { + return authenticateUserCode(credentials); + } + } + + private SSOAuthenticatedUser authenticateUserImplicit(Credentials credentials) + throws GuacamoleException { String username = null; Set groups = null; Map tokens = Collections.emptyMap(); // Validate OpenID token in request, if present, and derive username - String token = credentials.getParameter(TOKEN_PARAMETER_NAME); + String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); if (token != null) { - JwtClaims claims = tokenService.validateToken(token); + JwtClaims claims = tokenService.validateTokenImplicit(token); if (claims != null) { username = tokenService.processUsername(claims); groups = tokenService.processGroups(claims); @@ -112,22 +149,109 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS // OpenID authorization page via JavaScript) throw new GuacamoleInvalidCredentialsException("Invalid login.", new CredentialsInfo(Arrays.asList(new Field[] { - new RedirectField(TOKEN_PARAMETER_NAME, getLoginURI(), + new RedirectField(IMPLICIT_TOKEN_PARAMETER_NAME, getLoginURI(), new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) })) ); } + /** + * 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; + } + + private SSOAuthenticatedUser authenticateUserCode(Credentials credentials) + throws GuacamoleException { + + String username = null; + Set groups = null; + Map tokens = Collections.emptyMap(); + + // Recover session + String identifier = getSessionIdentifier(credentials); + OpenIDAuthenticationSession session = sessionManager.resume(identifier); + + // Validate OIDC token in request, if present, and derive username + if (session != null) { + String code = (String) session.getCode(); + if (code != null) { + JwtClaims claims = tokenService.validateTokenCode(code, session); + 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(AUTH_SESSION_QUERY_PARAM, getLoginURI(), + new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) + })) + ); + } + + private Boolean isImplicit() throws GuacamoleException { + String flowType = confService.getFlowType(); + return flowType.equals("implicit"); + } + @Override public URI getLoginURI() throws GuacamoleException { - return UriBuilder.fromUri(confService.getAuthorizationEndpoint()) - .queryParam("scope", confService.getScope()) - .queryParam("response_type", "id_token") - .queryParam("client_id", confService.getClientID()) - .queryParam("redirect_uri", confService.getRedirectURI()) - .queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)) - .build(); + if (isImplicit()) { + return UriBuilder.fromUri(confService.getAuthorizationEndpoint()) + .queryParam("scope", confService.getScope()) + .queryParam("response_type", IMPLICIT_TOKEN_PARAMETER_NAME) + .queryParam("client_id", confService.getClientID()) + .queryParam("redirect_uri", confService.getRedirectURI()) + .queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)) + .build(); + } else { + /** + * For Code flow and PKCE support we need access to the users session to + * store the PKCE verifier and be able to pass the returned code form the + * IdP to authenticateUser. Guacamole doesn't expose the user session here. + * We get around that by creating a redirect to a local REST api that then + * does the redirection/callback and stores the code and verifier in the + * user session. + */ + URI redirect = UriBuilder.fromUri(confService.getRedirectURI()) + .path(CODE_REDIRECT_API).build(); + return UriBuilder.fromUri(redirect) + .queryParam("scope", confService.getScope()) + .queryParam("response_type", CODE_TOKEN_PARAMETER_NAME) + .queryParam("client_id", confService.getClientID()) + .queryParam("redirect_uri", redirect) + .build(); + } } @Override @@ -156,7 +280,7 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @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/OpenIDAuthenticationProvider.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java index a760854a6..37353c31e 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java @@ -35,7 +35,7 @@ public class OpenIDAuthenticationProvider extends SSOAuthenticationProvider { * against an OpenID service. */ public OpenIDAuthenticationProvider() { - super(AuthenticationProviderService.class, SSOResource.class, + super(AuthenticationProviderService.class, OpenIDRedirectResource.class, new OpenIDAuthenticationProviderModule()); } 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 6dc45f7f9..59d278177 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 @@ -23,6 +23,8 @@ import com.google.inject.AbstractModule; import com.google.inject.Scopes; import org.apache.guacamole.auth.openid.conf.ConfigurationService; import org.apache.guacamole.auth.openid.conf.OpenIDEnvironment; +import org.apache.guacamole.auth.openid.OpenIDAuthenticationSessionManager; +import org.apache.guacamole.auth.openid.OpenIDRedirectResource; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.auth.openid.token.TokenValidationService; import org.apache.guacamole.environment.Environment; @@ -42,7 +44,7 @@ public class OpenIDAuthenticationProviderModule extends AbstractModule { 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 000000000..29eaa1efc --- /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,116 @@ +/* + * 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 String verifier = null; + + /** + * The redirect URI used by the identity provide. + */ + private String redirect_uri = null; + + /** + * The code returned by the identity provider use to exchange for a token + */ + private String code = null; + + /** + * 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(long expires) { + super(expires); + } + + /** + * Set the pkce_verifier + * + * @param verifier + * The verifier to be stored + */ + public void setVerifier(String verifier) { + this.verifier = verifier; + } + + /** + * Returns the stored PKCE verifier + * + * @return + * The PKCE verifier + */ + public String getVerifier() { + return verifier; + } + + /** + * Set the redirect URI sent to the identity provider + * + * @param redirect_uri + * The redirect UTI to be stored + */ + public void setRedirectURI(String redirect_uri) { + this.redirect_uri = redirect_uri; + } + + /** + * Returns the stored redirect URI sent to the identity provider + * + * @return + * The redirect URI + */ + public String getRedirectURI() { + return redirect_uri; + } + + /** + * Set the code returned by the identity provider to exchange for a token + * + * @param code + * The code to be stored + */ + public void setCode(String code) { + this.code = code; + } + + /** + * Returns the stored code returned by the identity provider to exchange for a token + * + * @return + * The stored code + */ + public String getCode() { + return code; + } + +} + 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 000000000..7eac71678 --- /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,83 @@ +/* + * 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 code returned by the identity provider to exchange for a token + * + * @param identifier + * The unique string returned by the call to defer(). For convenience, + * this value may safely be null. + * + * @return + * The code returned by the identity provider + */ + public String getCode(String identifier) { + OpenIDAuthenticationSession session = resume(identifier); + if (session != null) + return session.getCode(); + return null; + } + + /** + * 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; + } + + /** + * Returns the stored redirect URI 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 redirect URIused with the identity provider + */ + public String getRedirectURI(String identifier) { + OpenIDAuthenticationSession session = resume(identifier); + if (session != null) + return session.getRedirectURI(); + return null; + } +} + diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java new file mode 100644 index 000000000..6b37e47ec --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java @@ -0,0 +1,173 @@ +/* + + * 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.Inject; +import com.google.inject.Singleton; +import java.net.URI; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.auth.openid.AuthenticationProviderService; +import org.apache.guacamole.auth.openid.conf.ConfigurationService; +import org.apache.guacamole.auth.openid.OpenIDAuthenticationSessionManager; +import org.apache.guacamole.auth.openid.util.PKCEUtil; +import org.apache.guacamole.auth.sso.SSOResource; +import org.apache.guacamole.net.auth.IdentifierGenerator; + +/** + * Local REST endpoint used by Guacamole to initiate the OIDC login with code flow and PKCE. + * + * This endpoint: + * - receives the request from Guacamole Web UI + * - generates PKCE code_verifier and code_challenge + * - stores code_verifier in an temporary OpenIDAuthenicationSession + * - redirects the browser to the Identity Provider (Keycloak, etc.) + * + * This endpoint is the place where PKCE MUST be handled because when getLoginURI() in + * AuthenticationProvider is called the authentication hasn't started yet + */ +public class OpenIDRedirectResource extends SSOResource { + + /** + * The configuration service for this module. + */ + @Inject + private ConfigurationService confService; + + /** + * Manager of active OpenID authentication attempts. + */ + @Inject + private OpenIDAuthenticationSessionManager sessionManager; + + /** + * Local redirect endpoint invoked by Guacamole to pass to the identity provider + * for code flow. Used to create and store PKCE challenges and store code values + * returned by the identity provider. + * + * @param request + * The HttpServletRequest from Guacamole or the identity provider + * + * @return + * A redirect to the identity provider if the query parameter 'code' doesn't + * exist or a redirect to Guacamole to continue the authentication process + * with the values necessary to code flow in authenticateUser + * + * @throws GuacamoleException + * If the PKCE challenge can not be generated or the verifier recovered + */ + @GET + @Path("/redirect") + public Response redirectToFromIdentityProvider(@Context HttpServletRequest request) + throws GuacamoleException { + String code = request.getParameter("code"); + + if (code == null) { + UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()); + + // Copy inbound request params + @SuppressWarnings("unchecked") + Map params = (Map) (Map) request.getParameterMap(); + for (Map.Entry entry : params.entrySet()) { + for (String value : entry.getValue()) { + builder = builder.queryParam(entry.getKey(), value); + } + } + + // Create a new authentication session to represent this attempt while + // it is in progress, using the request ID that was just issued + OpenIDAuthenticationSession session = new OpenIDAuthenticationSession( + confService.getAuthenticationTimeout() * 60000L); + + // PKCE support + 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 + session.setVerifier(codeVerifier); + + builder.queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256"); + } + + // Store redirect_uri for exchange of code for token, requires exact same uri + String redirectURI = request.getParameter("redirect_uri"); + session.setRedirectURI(redirectURI); + + // Generate a unique ID to use to identify stored values + String identifier = IdentifierGenerator.generateIdentifier(); + builder.queryParam(AuthenticationProviderService.AUTH_SESSION_QUERY_PARAM, identifier); + + // Save the session with the stored values + sessionManager.defer(session, identifier); + + return Response.seeOther(builder.build()).build(); + } + + // Retrieve the stored session + String identifier = request.getParameter(AuthenticationProviderService.AUTH_SESSION_QUERY_PARAM); + OpenIDAuthenticationSession session = sessionManager.resume(identifier); + + if (confService.isPKCERequired()) { + // Retrieve stored PKCE verifier + String verifier = session.getVerifier(); + + if (verifier == null) + throw new GuacamoleException("Missing PKCE verifier from session."); + } + + // Retrieve stored redirect URI + String redirectURI = session.getRedirectURI(); + + if (redirectURI == null) + throw new GuacamoleException("Missing redirect URI from session."); + + // Store the authorization code for authenticateUser() + session.setCode(code); + + // Save the session with the stored values. Need to reactivate so it is + // available for the next resume + sessionManager.defer(session, identifier); + sessionManager.reactivateSession(identifier); + + // Redirect browser back to Guacamole UI to continue login + URI resume = UriBuilder.fromUri(confService.getRedirectURI()) + .queryParam(AuthenticationProviderService.AUTH_SESSION_QUERY_PARAM, identifier) + .build(); + return Response.seeOther(resume).build(); + } +} 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 29aee3139..2a35dfa4c 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 @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.List; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.BooleanGuacamoleProperty; import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; @@ -36,6 +37,11 @@ import org.apache.guacamole.properties.URIGuacamoleProperty; */ public class ConfigurationService { + /** + * The default OICD flow type + */ + private static final String DEFAULT_FLOW_TYPE = "implicit"; + /** * The default claim type to use to retrieve an authenticated user's * username. @@ -110,6 +116,66 @@ 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 flow type of the OpenID service. + */ + private static final StringGuacamoleProperty OPENID_FLOW_TYPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-flow-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 to allow for an in-progress OpenID + * authentication attempt to be completed, in minutes. A user that takes + * longer than this amount of time to complete authentication with their + * identity provider will be redirected back to the identity provider to + * try again. + */ + private static final IntegerGuacamoleProperty OPENID_AUTH_TIMEOUT = + new IntegerGuacamoleProperty() { + @Override + public String getName() { return "openid-auth-timeout"; } + + }; + + /** * The claim type which contains the authenticated user's username within * any valid JWT. @@ -337,6 +403,90 @@ public class ConfigurationService { return environment.getRequiredProperty(OPENID_JWKS_ENDPOINT); } + /** + * 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 authorization + * endpoint property is missing. + */ + public URI getTokenEndpoint() throws GuacamoleException { + return environment.getRequiredProperty(OPENID_TOKEN_ENDPOINT); + } + + /** + * Returns the flow type of the OpenID service as configured with guacamole.properties. + * + * @return + * The flow type of the OpenID service, as configured with guacamole.properties. Can + * be either 'implicit' or 'code'. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public String getFlowType() throws GuacamoleException { + return environment.getProperty(OPENID_FLOW_TYPE, DEFAULT_FLOW_TYPE); + } + + /** + * 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 to allow for an in-progress OpenID + * authentication attempt to be completed, in minutes. A user that takes + * longer than this amount of time to complete authentication with their + * identity provider will be redirected back to the identity provider to + * try again. + * + * @return + * The maximum amount of time to allow for an in-progress SAML + * authentication attempt to be completed, in minutes. + * + * @throws GuacamoleException + * If the authentication timeout cannot be parsed. + */ + public int getAuthenticationTimeout() throws GuacamoleException { + return environment.getProperty(OPENID_AUTH_TIMEOUT, 5); + } + + /** * Returns the claim type which contains the authenticated user's username * within any valid JWT, as configured with guacamole.properties. By 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 35a515837..589edd005 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,14 @@ package org.apache.guacamole.auth.openid.token; import com.google.inject.Inject; +import java.io.BufferedReader; +import java.io.OutputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -27,10 +35,14 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.openid.conf.ConfigurationService; +import org.apache.guacamole.auth.openid.OpenIDAuthenticationSession; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.token.TokenName; +import org.jose4j.json.JsonUtil; import org.jose4j.jwk.HttpsJwks; import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.MalformedClaimException; @@ -70,8 +82,8 @@ 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, returning the JwtClaims + * contained therein. If the ID token is invalid, null is returned. * * @param token * The ID token to validate. @@ -83,7 +95,7 @@ public class TokenValidationService { * @throws GuacamoleException * If guacamole.properties could not be parsed. */ - public JwtClaims validateToken(String token) throws GuacamoleException { + public JwtClaims validateTokenImplicit(String token) throws GuacamoleException { // Validating the token requires a JWKS key resolver HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); @@ -131,6 +143,156 @@ public class TokenValidationService { return null; } + /** + * Validates the given ID token, using code flow, returning the JwtClaims + * contained therein. If the ID token is invalid, null is returned. + * + * @param code + * The code to validate and receive the id_token. + * + * @param session + * A OpenIDAuthenicationSession storing the in progress authentication parameters + * + * @return + * The JWT claims contained within the given ID token if it passes tests, + * or null if the token is not valid. + * + * @throws GuacamoleException + * If guacamole.properties could not be parsed. + */ + public JwtClaims validateTokenCode(String code, OpenIDAuthenticationSession session) throws GuacamoleException { + // Validating the token requires a JWKS key resolver + HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); + HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); + + /* Exchange code → token */ + String token = exchangeCode(code, session); + + // Create JWT consumer for validating received token + JwtConsumer jwtConsumer = new JwtConsumerBuilder() + .setRequireExpirationTime() + .setMaxFutureValidityInMinutes(confService.getMaxTokenValidity()) + .setAllowedClockSkewInSeconds(confService.getAllowedClockSkew()) + .setRequireSubject() + .setExpectedIssuer(confService.getIssuer()) + .setExpectedAudience(confService.getClientID()) + .setVerificationKeyResolver(resolver) + .build(); + + try { + // Validate JWT + return jwtConsumer.processToClaims(token); + } + // Log any failures to validate/parse the JWT + catch (InvalidJwtException e) { + logger.info("Rejected invalid OpenID token: {}", e.getMessage(), e); + } + + 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, OpenIDAuthenticationSession session) 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", session.getRedirectURI())).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()) { + String codeVerifier = session.getVerifier(); + bodyBuilder.append("&").append(urlencode("code_verifier", codeVerifier)); + } + + // Build the final URI and convert to a URL + URL url = confService.getTokenEndpoint().toURL(); + + // Open connection, using HttpURLConnection + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8" + ); + + try (OutputStream out = conn.getOutputStream()) { + byte [] body = bodyBuilder.toString().getBytes(StandardCharsets.UTF_8); + out.write(body, 0, body.length); + } + + // Read response + int status = conn.getResponseCode(); + + BufferedReader reader = new BufferedReader( + new InputStreamReader( + status >= 200 && status < 300 + ? conn.getInputStream() + : conn.getErrorStream(), + StandardCharsets.UTF_8 + ) + ); + + StringBuilder responseBody = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + responseBody.append(line); + } + reader.close(); + + Map json = JsonUtil.parseJson(responseBody.toString()); + + if (status < 200 || status >= 300) { + throw new GuacamoleException("Token endpoint error (" + status + "): " + json.toString()); + } + + return (String) json.get("id_token"); + + } catch (Exception e) { + throw new GuacamoleException("Token exchange failed.", e); + } + } + /** * Parses the given JwtClaims, returning the username contained * therein, as defined by the username claim type given in 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 000000000..a92876d95 --- /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,64 @@ +/* + * 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 { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private PKCEUtil() {} + + /** + * Generates a high-entropy PKCE code_verifier. + */ + public static String generateCodeVerifier() { + byte[] bytes = new byte[64]; + RANDOM.nextBytes(bytes); + return base64Url(bytes); + } + + /** + * Computes the PKCE code_challenge = BASE64URL(SHA256(code_verifier)). + */ + 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. + */ + public static String base64Url(byte[] bytes) { + return java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes); + } +}