From c1ce59517e5ffd6bdbdf0080bf18995a8c5cbb79 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 8 Apr 2026 01:03:50 +0200 Subject: [PATCH 01/14] Add code flow authorisation type, with or without a client secret and with or without pkce --- .../openid/AuthenticationProviderService.java | 150 +++++++++++++-- .../openid/OpenIDAuthenticationProvider.java | 2 +- .../OpenIDAuthenticationProviderModule.java | 4 +- .../openid/OpenIDAuthenticationSession.java | 116 ++++++++++++ .../OpenIDAuthenticationSessionManager.java | 83 +++++++++ .../auth/openid/OpenIDRedirectResource.java | 173 ++++++++++++++++++ .../openid/conf/ConfigurationService.java | 150 +++++++++++++++ .../openid/token/TokenValidationService.java | 168 ++++++++++++++++- .../guacamole/auth/openid/util/PKCEUtil.java | 64 +++++++ 9 files changed, 892 insertions(+), 18 deletions(-) create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSession.java create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationSessionManager.java create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/PKCEUtil.java 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..7c48e0f22c 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.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 SSOAuthenticatedUser authenticateUser(Credentials credentials) // 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 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/OpenIDAuthenticationProvider.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java index a760854a6e..37353c31e6 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 6dc45f7f97..59d278177c 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.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 @@ 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..29eaa1efc4 --- /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 0000000000..7eac71678c --- /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 0000000000..6b37e47ec1 --- /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 29aee31396..2a35dfa4c5 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.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 @@ */ 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 URI getJWKSEndpoint() throws GuacamoleException { 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 35a5158378..589edd0051 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.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 JwtClaims validateToken(String token) throws GuacamoleException { 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 0000000000..a92876d95b --- /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); + } +} From 20f78d42b6da9e44b5adf37191246fa30ac13158 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 8 Apr 2026 16:57:35 +0200 Subject: [PATCH 02/14] Use SessionManager directly from getLoginURI to store PKCE removing the need for local REST redirect and callback function --- .../openid/AuthenticationProviderService.java | 152 +++++++-------- .../openid/OpenIDAuthenticationProvider.java | 2 +- .../OpenIDAuthenticationProviderModule.java | 1 - .../openid/OpenIDAuthenticationSession.java | 64 +------ .../OpenIDAuthenticationSessionManager.java | 33 ---- .../auth/openid/OpenIDRedirectResource.java | 173 ------------------ .../openid/conf/ConfigurationService.java | 2 +- .../auth/openid/conf/OpenIDEnvironment.java | 4 +- .../openid/token/TokenValidationService.java | 31 ++-- 9 files changed, 90 insertions(+), 372 deletions(-) delete mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java 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 7c48e0f22c..111a352910 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 @@ -31,8 +31,8 @@ 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.auth.openid.util.PKCEUtil; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService; @@ -43,14 +43,25 @@ 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; + +import org.apache.guacamole.net.auth.IdentifierGenerator; + /** * Service that authenticates Guacamole users by processing OpenID tokens. */ @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. @@ -63,7 +74,7 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS * 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). @@ -106,56 +117,6 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS @Inject private Provider authenticatedUserProvider; - @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(IMPLICIT_TOKEN_PARAMETER_NAME); - if (token != null) { - JwtClaims claims = tokenService.validateTokenImplicit(token); - 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(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 @@ -175,22 +136,40 @@ public static String getSessionIdentifier(Credentials credentials) { return credentials != null ? credentials.getParameter(AUTH_SESSION_QUERY_PARAM) : null; } - private SSOAuthenticatedUser authenticateUserCode(Credentials credentials) + @Override + public SSOAuthenticatedUser authenticateUser(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); + logger.debug("OpenID authentication with {} flow (ID: {}, Secret: {}, PKCE: {})", + confService.getFlowType(), + confService.getClientID(), + confService.getClientSecret(), + confService.isPKCERequired()); + + if (confService.getFlowType().equals("implicit")) { + String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); + JwtClaims claims = tokenService.validateToken(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.validateCode(code, verifier); if (claims != null) { username = tokenService.processUsername(claims); groups = tokenService.processGroups(claims); @@ -206,7 +185,6 @@ private SSOAuthenticatedUser authenticateUserCode(Credentials credentials) SSOAuthenticatedUser authenticatedUser = authenticatedUserProvider.get(); authenticatedUser.init(username, credentials, groups, tokens); return authenticatedUser; - } // Request OpenID token (will automatically redirect the user to the @@ -219,14 +197,9 @@ private SSOAuthenticatedUser authenticateUserCode(Credentials credentials) ); } - private Boolean isImplicit() throws GuacamoleException { - String flowType = confService.getFlowType(); - return flowType.equals("implicit"); - } - @Override public URI getLoginURI() throws GuacamoleException { - if (isImplicit()) { + if (confService.getFlowType().equals("implicit")) { return UriBuilder.fromUri(confService.getAuthorizationEndpoint()) .queryParam("scope", confService.getScope()) .queryParam("response_type", IMPLICIT_TOKEN_PARAMETER_NAME) @@ -234,23 +207,36 @@ public URI getLoginURI() throws GuacamoleException { .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) + } else { + UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) .queryParam("scope", confService.getScope()) .queryParam("response_type", CODE_TOKEN_PARAMETER_NAME) .queryParam("client_id", confService.getClientID()) - .queryParam("redirect_uri", redirect) - .build(); + .queryParam("redirect_uri", confService.getRedirectURI()); + + 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.getAuthenticationTimeout() * 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(); } } 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 37353c31e6..a760854a6e 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, OpenIDRedirectResource.class, + super(AuthenticationProviderService.class, SSOResource.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 59d278177c..bbd5c4c348 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 @@ -24,7 +24,6 @@ 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; 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 index 29eaa1efc4..320f0609d2 100644 --- 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 @@ -28,17 +28,7 @@ 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; + private final String verifier; /** * Creates a new AuthenticationSession representing an in-progress OpenID @@ -48,17 +38,8 @@ public class OpenIDAuthenticationSession extends AuthenticationSession { * The number of milliseconds that may elapse before this session must * be considered invalid. */ - public OpenIDAuthenticationSession(long expires) { + public OpenIDAuthenticationSession(String verifier, long expires) { super(expires); - } - - /** - * Set the pkce_verifier - * - * @param verifier - * The verifier to be stored - */ - public void setVerifier(String verifier) { this.verifier = verifier; } @@ -71,46 +52,5 @@ public void setVerifier(String 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 index 7eac71678c..8a2371259e 100644 --- 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 @@ -29,22 +29,6 @@ @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 @@ -62,22 +46,5 @@ public String getVerifier(String identifier) { 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 deleted file mode 100644 index 6b37e47ec1..0000000000 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDRedirectResource.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - - * 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 2a35dfa4c5..cf7bc1407e 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 @@ -172,7 +172,7 @@ public String getName() { new IntegerGuacamoleProperty() { @Override public String getName() { return "openid-auth-timeout"; } - + }; diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java index 4404630813..cb80689a22 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java @@ -27,7 +27,7 @@ * configuration. */ public class OpenIDEnvironment extends DelegatingEnvironment { - + /** * Create a new instance of the configuration environment for the * OpenID SSO module, pulling the default instance of the LocalEnvironment. @@ -35,5 +35,5 @@ public class OpenIDEnvironment extends DelegatingEnvironment { public OpenIDEnvironment() { super(LocalEnvironment.getInstance()); } - + } 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 589edd0051..fd9f4c16a2 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 @@ -39,7 +39,6 @@ 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; @@ -95,7 +94,7 @@ public class TokenValidationService { * @throws GuacamoleException * If guacamole.properties could not be parsed. */ - public JwtClaims validateTokenImplicit(String token) throws GuacamoleException { + public JwtClaims validateToken(String token) throws GuacamoleException { // Validating the token requires a JWKS key resolver HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); @@ -150,8 +149,8 @@ public JwtClaims validateTokenImplicit(String token) throws GuacamoleException { * @param code * The code to validate and receive the id_token. * - * @param session - * A OpenIDAuthenicationSession storing the in progress authentication parameters + * @param verifier + * A PKCE verifier or null if not used. * * @return * The JWT claims contained within the given ID token if it passes tests, @@ -160,13 +159,13 @@ public JwtClaims validateTokenImplicit(String token) throws GuacamoleException { * @throws GuacamoleException * If guacamole.properties could not be parsed. */ - public JwtClaims validateTokenCode(String code, OpenIDAuthenticationSession session) throws GuacamoleException { + public JwtClaims validateCode(String code, String verifier) 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); + String token = exchangeCode(code, verifier); // Create JWT consumer for validating received token JwtConsumer jwtConsumer = new JwtConsumerBuilder() @@ -190,7 +189,7 @@ public JwtClaims validateTokenCode(String code, OpenIDAuthenticationSession sess return null; } - + /** * URLEncodes a key/value pair * @@ -225,26 +224,25 @@ private String urlencode(String key, String value) { * @throws GuacamoleException * If a valid token is not returned. */ - private String exchangeCode(String code, OpenIDAuthenticationSession session) throws GuacamoleException { + 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", session.getRedirectURI())).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()) { - String codeVerifier = session.getVerifier(); - bodyBuilder.append("&").append(urlencode("code_verifier", codeVerifier)); + bodyBuilder.append("&").append(urlencode("code_verifier", verifier)); } - + // Build the final URI and convert to a URL URL url = confService.getTokenEndpoint().toURL(); @@ -289,14 +287,15 @@ private String exchangeCode(String code, OpenIDAuthenticationSession session) th return (String) json.get("id_token"); } catch (Exception e) { - throw new GuacamoleException("Token exchange failed.", 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 - * guacamole.properties. If the username claim type is missing or + * guacamole.properties. If the username claim type is missing or * is invalid, null is returned. * * @param claims @@ -360,7 +359,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); } From 4dae87335ffc0043ff87c115442b16e313121285 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 8 Apr 2026 17:03:33 +0200 Subject: [PATCH 03/14] Reinclude accidentally removed check if implicit token is null --- .../auth/openid/AuthenticationProviderService.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 111a352910..1513b9b18d 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 @@ -152,11 +152,13 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) if (confService.getFlowType().equals("implicit")) { String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); - JwtClaims claims = tokenService.validateToken(token); - if (claims != null) { - username = tokenService.processUsername(claims); - groups = tokenService.processGroups(claims); - tokens = tokenService.processAttributes(claims); + if (token != null) { + JwtClaims claims = tokenService.validateToken(token); + if (claims != null) { + username = tokenService.processUsername(claims); + groups = tokenService.processGroups(claims); + tokens = tokenService.processAttributes(claims); + } } } else { String verifier = null; From 9f4bb61fcae2069b2ee40457fde25135a96a6441 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 8 Apr 2026 19:46:19 +0200 Subject: [PATCH 04/14] Minor white-spece, import changes to minimize impact on existing code --- .../openid/AuthenticationProviderService.java | 11 +- .../AuthenticationProviderService.java.orig | 274 ++++++++ ...penIDAuthenticationProviderModule.java.rej | 11 + .../openid/conf/ConfigurationService.java | 1 - .../conf/ConfigurationService.java.orig | 639 ++++++++++++++++++ .../auth/openid/conf/OpenIDEnvironment.java | 4 +- .../openid/token/TokenValidationService.java | 8 +- .../token/TokenValidationService.java.orig | 430 ++++++++++++ 8 files changed, 1362 insertions(+), 16 deletions(-) create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig 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 1513b9b18d..2647bbb917 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 @@ -45,11 +45,9 @@ 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; -import org.apache.guacamole.net.auth.IdentifierGenerator; /** * Service that authenticates Guacamole users by processing OpenID tokens. @@ -81,12 +79,6 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS */ 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. */ @@ -94,7 +86,7 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS private ConfigurationService confService; /** - * Manager of active SAML authentication attempts. + * Manager of active OpenID authentication attempts. */ @Inject private OpenIDAuthenticationSessionManager sessionManager; @@ -197,6 +189,7 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) })) ); + } @Override diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig new file mode 100644 index 0000000000..1513b9b18d --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig @@ -0,0 +1,274 @@ +/* + * 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.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.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.user.SSOAuthenticatedUser; +import org.apache.guacamole.form.Field; +import org.apache.guacamole.form.RedirectField; +import org.apache.guacamole.language.TranslatableMessage; +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; + +import org.apache.guacamole.net.auth.IdentifierGenerator; + +/** + * Service that authenticates Guacamole users by processing OpenID tokens. + */ +@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 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. + */ + @Inject + private ConfigurationService confService; + + /** + * Manager of active SAML authentication attempts. + */ + @Inject + private OpenIDAuthenticationSessionManager sessionManager; + + /** + * Service for validating and generating unique nonce values. + */ + @Inject + private NonceService nonceService; + + /** + * Service for validating received ID tokens. + */ + @Inject + private TokenValidationService tokenService; + + /** + * Provider for AuthenticatedUser objects. + */ + @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 { + + String username = null; + Set groups = null; + Map tokens = Collections.emptyMap(); + + logger.debug("OpenID authentication with {} flow (ID: {}, Secret: {}, PKCE: {})", + confService.getFlowType(), + confService.getClientID(), + confService.getClientSecret(), + confService.isPKCERequired()); + + if (confService.getFlowType().equals("implicit")) { + String token = credentials.getParameter(IMPLICIT_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); + } + } + } 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.validateCode(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(AUTH_SESSION_QUERY_PARAM, getLoginURI(), + new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) + })) + ); + } + + @Override + public URI getLoginURI() throws GuacamoleException { + if (confService.getFlowType().equals("implicit")) { + 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 { + UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) + .queryParam("scope", confService.getScope()) + .queryParam("response_type", CODE_TOKEN_PARAMETER_NAME) + .queryParam("client_id", confService.getClientID()) + .queryParam("redirect_uri", confService.getRedirectURI()); + + 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.getAuthenticationTimeout() * 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 + public URI getLogoutURI(String idToken) throws GuacamoleException { + + // If no logout endpoint is configured, return null + URI logoutEndpoint = confService.getLogoutEndpoint(); + if (logoutEndpoint == null) + return null; + + // Build the logout URI with appropriate parameters + UriBuilder logoutUriBuilder = UriBuilder.fromUri(logoutEndpoint); + + // Add post_logout_redirect_uri parameter + logoutUriBuilder.queryParam("post_logout_redirect_uri", + confService.getPostLogoutRedirectURI()); + + // Add id_token_hint if available, otherwise add client_id + if (idToken != null && !idToken.isEmpty()) + logoutUriBuilder.queryParam("id_token_hint", idToken); + else + logoutUriBuilder.queryParam("client_id", confService.getClientID()); + + return logoutUriBuilder.build(); + } + + @Override + public void shutdown() { + sessionManager.shutdown(); + } + +} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej new file mode 100644 index 0000000000..588486f9b8 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej @@ -0,0 +1,11 @@ +--- extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java 2026-04-08 19:37:41.518619970 +0200 ++++ extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java 2026-04-08 16:43:54.617087120 +0200 +@@ -37,7 +37,7 @@ + * The configuration environment for this server and extension. + */ + private final Environment environment = new OpenIDEnvironment(); +- ++ + @Override + protected void configure() { + bind(ConfigurationService.class); 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 cf7bc1407e..7e0fa78794 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 @@ -486,7 +486,6 @@ 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/conf/ConfigurationService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig new file mode 100644 index 0000000000..cf7bc1407e --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig @@ -0,0 +1,639 @@ +/* + * 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 java.net.URI; +import java.util.Collection; +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; + +/** + * Service for retrieving configuration information regarding the OpenID + * service. + */ +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. + */ + private static final String DEFAULT_USERNAME_CLAIM_TYPE = "email"; + + /** + * The default claim type to use to retrieve an authenticated user's + * groups. + */ + private static final String DEFAULT_GROUPS_CLAIM_TYPE = "groups"; + + /** + * The default JWT claims list to map to tokens. + */ + private static final List DEFAULT_ATTRIBUTES_CLAIM_TYPE = Collections.emptyList(); + + /** + * The default space-separated list of OpenID scopes to request. + */ + private static final String DEFAULT_SCOPE = "openid email profile"; + + /** + * The default amount of clock skew tolerated for timestamp comparisons + * between the Guacamole server and OpenID service clocks, in seconds. + */ + private static final int DEFAULT_ALLOWED_CLOCK_SKEW = 30; + + /** + * The default maximum amount of time that an OpenID token should remain + * valid, in minutes. + */ + private static final int DEFAULT_MAX_TOKEN_VALIDITY = 300; + + /** + * The default maximum amount of time that a nonce generated by the + * Guacamole server should remain valid, in minutes. + */ + private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; + + /** + * The authorization endpoint (URI) of the OpenID service. + */ + private static final URIGuacamoleProperty OPENID_AUTHORIZATION_ENDPOINT = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-authorization-endpoint"; } + + }; + + /** + * The endpoint (URI) of the JWKS service which defines how received ID + * tokens (JWTs) shall be validated. + */ + private static final URIGuacamoleProperty OPENID_JWKS_ENDPOINT = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-jwks-endpoint"; } + + }; + + /** + * The issuer to expect for all received ID tokens. + */ + private static final StringGuacamoleProperty OPENID_ISSUER = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-issuer"; } + + }; + + /** + * 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. + */ + private static final StringGuacamoleProperty OPENID_USERNAME_CLAIM_TYPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-username-claim-type"; } + + }; + + /** + * The claim type which contains the authenticated user's groups within + * any valid JWT. + */ + private static final StringGuacamoleProperty OPENID_GROUPS_CLAIM_TYPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-groups-claim-type"; } + + }; + + /** + * The claims within any valid JWT that should be mapped to + * the authenticated user's tokens, as configured with guacamole.properties. + */ + private static final StringGuacamoleProperty OPENID_ATTRIBUTES_CLAIM_TYPE = + new StringGuacamoleProperty() { + @Override + public String getName() { return "openid-attributes-claim-type"; } + }; + + /** + * The space-separated list of OpenID scopes to request. + */ + private static final StringGuacamoleProperty OPENID_SCOPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-scope"; } + + }; + + /** + * The amount of clock skew tolerated for timestamp comparisons between the + * Guacamole server and OpenID service clocks, in seconds. + */ + private static final IntegerGuacamoleProperty OPENID_ALLOWED_CLOCK_SKEW = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "openid-allowed-clock-skew"; } + + }; + + /** + * The maximum amount of time that an OpenID token should remain valid, in + * minutes. + */ + private static final IntegerGuacamoleProperty OPENID_MAX_TOKEN_VALIDITY = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "openid-max-token-validity"; } + + }; + + /** + * The maximum amount of time that a nonce generated by the Guacamole server + * should remain valid, in minutes. As each OpenID request has a unique + * nonce value, this imposes an upper limit on the amount of time any + * particular OpenID request can result in successful authentication within + * Guacamole. + */ + private static final IntegerGuacamoleProperty OPENID_MAX_NONCE_VALIDITY = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "openid-max-nonce-validity"; } + + }; + + /** + * OpenID client ID which should be submitted to the OpenID service when + * necessary. This value is typically provided by the OpenID service when + * OpenID credentials are generated for your application. + */ + private static final StringGuacamoleProperty OPENID_CLIENT_ID = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "openid-client-id"; } + + }; + + /** + * The URI that the OpenID service should redirect to after the + * authentication process is complete. This must be the full URL that a + * user would enter into their browser to access Guacamole. + */ + private static final URIGuacamoleProperty OPENID_REDIRECT_URI = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-redirect-uri"; } + + }; + + /** + * The logout endpoint (URI) of the OpenID service. If specified, users + * will be redirected to this endpoint when they log out, allowing them + * to log out from the OpenID provider as well. + */ + private static final URIGuacamoleProperty OPENID_LOGOUT_ENDPOINT = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-logout-endpoint"; } + + }; + + /** + * The URI that the OpenID service should redirect to after logout is + * complete. If not specified, the main redirect URI will be used. + */ + private static final URIGuacamoleProperty OPENID_POST_LOGOUT_REDIRECT_URI = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "openid-post-logout-redirect-uri"; } + + }; + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * Returns the authorization endpoint (URI) of the OpenID service as + * configured with guacamole.properties. + * + * @return + * The authorization endpoint of the OpenID service, as configured with + * 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); + } + + /** + * Returns the OpenID client ID which should be submitted to the OpenID + * service when necessary, as configured with guacamole.properties. This + * value is typically provided by the OpenID service when OpenID credentials + * are generated for your application. + * + * @return + * The client ID to use when communicating with the OpenID service, + * as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed, or if the client ID + * property is missing. + */ + public String getClientID() throws GuacamoleException { + return environment.getRequiredProperty(OPENID_CLIENT_ID); + } + + /** + * Returns the URI that the OpenID service should redirect to after + * the authentication process is complete, as configured with + * guacamole.properties. This must be the full URL that a user would enter + * into their browser to access Guacamole. + * + * @return + * The client secret to use when communicating with the OpenID service, + * as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed, or if the redirect URI + * property is missing. + */ + public URI getRedirectURI() throws GuacamoleException { + return environment.getRequiredProperty(OPENID_REDIRECT_URI); + } + + /** + * Returns the issuer to expect for all received ID tokens, as configured + * with guacamole.properties. + * + * @return + * The issuer to expect for all received ID tokens, as configured with + * guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed, or if the issuer property + * is missing. + */ + public String getIssuer() throws GuacamoleException { + return environment.getRequiredProperty(OPENID_ISSUER); + } + + /** + * Returns the endpoint (URI) of the JWKS service which defines how + * received ID tokens (JWTs) shall be validated, as configured with + * guacamole.properties. + * + * @return + * The endpoint (URI) of the JWKS service which defines how received ID + * tokens (JWTs) shall be validated, as configured with + * guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed, or if the JWKS endpoint + * property is missing. + */ + public URI getJWKSEndpoint() throws GuacamoleException { + 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 + * default, this will be "email". + * + * @return + * The claim type which contains the authenticated user's username + * within any valid JWT, as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public String getUsernameClaimType() throws GuacamoleException { + return environment.getProperty(OPENID_USERNAME_CLAIM_TYPE, DEFAULT_USERNAME_CLAIM_TYPE); + } + + /** + * Returns the claim type which contains the authenticated user's groups + * within any valid JWT, as configured with guacamole.properties. By + * default, this will be "groups". + * + * @return + * The claim type which contains the authenticated user's groups + * within any valid JWT, as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public String getGroupsClaimType() throws GuacamoleException { + return environment.getProperty(OPENID_GROUPS_CLAIM_TYPE, DEFAULT_GROUPS_CLAIM_TYPE); + } + + /** + * Returns the claims list within any valid JWT that should be mapped to + * the authenticated user's tokens, as configured with guacamole.properties. + * Empty by default. + * + * @return + * The claims list within any valid JWT that should be mapped to + * the authenticated user's tokens, as configured with guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public Collection getAttributesClaimType() throws GuacamoleException { + return environment.getPropertyCollection(OPENID_ATTRIBUTES_CLAIM_TYPE, DEFAULT_ATTRIBUTES_CLAIM_TYPE); + } + + /** + * Returns the space-separated list of OpenID scopes to request. By default, + * this will be "openid email profile". The OpenID scopes determine the + * information returned within the OpenID token, and thus affect what + * values can be used as an authenticated user's username. + * + * @return + * The space-separated list of OpenID scopes to request when identifying + * a user. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public String getScope() throws GuacamoleException { + return environment.getProperty(OPENID_SCOPE, DEFAULT_SCOPE); + } + + /** + * Returns the amount of clock skew tolerated for timestamp comparisons + * between the Guacamole server and OpenID service clocks, in seconds. Too + * much clock skew will affect token expiration calculations, possibly + * allowing old tokens to be used. By default, this will be 30. + * + * @return + * The amount of clock skew tolerated for timestamp comparisons, in + * seconds. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public int getAllowedClockSkew() throws GuacamoleException { + return environment.getProperty(OPENID_ALLOWED_CLOCK_SKEW, DEFAULT_ALLOWED_CLOCK_SKEW); + } + + /** + * Returns the maximum amount of time that an OpenID token should remain + * valid, in minutes. A token received from an OpenID service which is + * older than this amount of time will be rejected, even if it is otherwise + * valid. By default, this will be 300 (5 hours). + * + * @return + * The maximum amount of time that an OpenID token should remain valid, + * in minutes. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public int getMaxTokenValidity() throws GuacamoleException { + return environment.getProperty(OPENID_MAX_TOKEN_VALIDITY, DEFAULT_MAX_TOKEN_VALIDITY); + } + + /** + * Returns the maximum amount of time that a nonce generated by the + * Guacamole server should remain valid, in minutes. As each OpenID request + * has a unique nonce value, 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 nonce generated by the Guacamole + * server should remain valid, in minutes. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public int getMaxNonceValidity() throws GuacamoleException { + return environment.getProperty(OPENID_MAX_NONCE_VALIDITY, DEFAULT_MAX_NONCE_VALIDITY); + } + + /** + * Returns the logout endpoint (URI) of the OpenID service, as configured + * with guacamole.properties. If configured, users will be redirected to + * this endpoint when they log out from Guacamole. + * + * @return + * The logout endpoint of the OpenID service, as configured with + * guacamole.properties, or null if not configured. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public URI getLogoutEndpoint() throws GuacamoleException { + return environment.getProperty(OPENID_LOGOUT_ENDPOINT); + } + + /** + * Returns the URI that the OpenID service should redirect to after logout + * is complete, as configured with guacamole.properties. If not configured, + * the main redirect URI will be used. + * + * @return + * The post-logout redirect URI, as configured with + * guacamole.properties, or the main redirect URI if not specified. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public URI getPostLogoutRedirectURI() throws GuacamoleException { + return environment.getProperty(OPENID_POST_LOGOUT_REDIRECT_URI, getRedirectURI()); + } + +} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java index cb80689a22..4404630813 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDEnvironment.java @@ -27,7 +27,7 @@ * configuration. */ public class OpenIDEnvironment extends DelegatingEnvironment { - + /** * Create a new instance of the configuration environment for the * OpenID SSO module, pulling the default instance of the LocalEnvironment. @@ -35,5 +35,5 @@ public class OpenIDEnvironment extends DelegatingEnvironment { public OpenIDEnvironment() { super(LocalEnvironment.getInstance()); } - + } 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 fd9f4c16a2..31bb4c3ee5 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 @@ -35,7 +35,7 @@ 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; @@ -189,7 +189,7 @@ public JwtClaims validateCode(String code, String verifier) throws GuacamoleExce return null; } - + /** * URLEncodes a key/value pair * @@ -295,7 +295,7 @@ private String exchangeCode(String code, String verifier) throws GuacamoleExcept /** * Parses the given JwtClaims, returning the username contained * therein, as defined by the username claim type given in - * guacamole.properties. If the username claim type is missing or + * guacamole.properties. If the username claim type is missing or * is invalid, null is returned. * * @param claims @@ -359,7 +359,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/token/TokenValidationService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig new file mode 100644 index 0000000000..fd9f4c16a2 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig @@ -0,0 +1,430 @@ +/* + * 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.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; +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.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; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service for validating ID tokens forwarded to us by the client, verifying + * that they did indeed come from the OpenID service. + */ +public class TokenValidationService { + + /** + * Logger for this class. + */ + private final Logger logger = LoggerFactory.getLogger(TokenValidationService.class); + + /** + * The prefix to use when generating token names. + */ + public static final String OIDC_ATTRIBUTE_TOKEN_PREFIX = "OIDC_"; + + /** + * Service for retrieving OpenID configuration information. + */ + @Inject + private ConfigurationService confService; + + /** + * Service for validating and generating unique nonce values. + */ + @Inject + private NonceService nonceService; + + /** + * 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. + * + * @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 validateToken(String token) throws GuacamoleException { + // Validating the token requires a JWKS key resolver + HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); + HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); + + // 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 + 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; + } + else { + logger.info("Rejected OpenID token with invalid/old nonce."); + } + } + else { + logger.info("Rejected OpenID token without nonce."); + } + } + // Log any failures to validate/parse the JWT + catch (MalformedClaimException e) { + logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); + } + catch (InvalidJwtException e) { + logger.info("Rejected invalid OpenID token: {}", e.getMessage(), e); + } + + 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 verifier + * A PKCE verifier or null if not used. + * + * @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 validateCode(String code, String verifier) 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, verifier); + + // 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, 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)); + } + + // 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) { + 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 + * guacamole.properties. If the username claim type is missing or + * is invalid, null is returned. + * + * @param claims + * A valid JwtClaims to extract the username from. + * + * @return + * The username contained within the given JwtClaims, or null if the + * claim is not valid or the username claim type is missing, + * + * @throws GuacamoleException + * If guacamole.properties could not be parsed. + */ + public String processUsername(JwtClaims claims) throws GuacamoleException { + String usernameClaim = confService.getUsernameClaimType(); + + if (claims != null) { + try { + // Pull username from claims + String username = claims.getStringClaimValue(usernameClaim); + if (username != null) + return username; + } + catch (MalformedClaimException e) { + logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); + } + + // Warn if username was not present in token, as it likely means + // the system is not set up correctly + logger.warn("Username claim \"{}\" missing from token. Perhaps the " + + "OpenID scope and/or username claim type are " + + "misconfigured?", usernameClaim); + } + + // Could not retrieve username from JWT + return null; + } + + /** + * Parses the given JwtClaims, returning the groups contained + * therein, as defined by the groups claim type given in + * guacamole.properties. If the groups claim type is missing or + * is invalid, an empty set is returned. + * + * @param claims + * A valid JwtClaims to extract groups from. + * + * @return + * A Set of String representing the groups the user is member of + * from the OpenID provider point of view, or an empty Set if + * claim is not valid or the groups claim type is missing, + * + * @throws GuacamoleException + * If guacamole.properties could not be parsed. + */ + public Set processGroups(JwtClaims claims) throws GuacamoleException { + String groupsClaim = confService.getGroupsClaimType(); + + if (claims != null) { + try { + // Pull groups from claims + 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); + } + } + + // Could not retrieve groups from JWT + return Collections.emptySet(); + } + + /** + * Parses the given JwtClaims, returning the attributes contained + * therein, as defined by the attributes claim type given in + * guacamole.properties. If the attributes claim type is missing or + * is invalid, an empty set is returned. + * + * @param claims + * A valid JwtClaims to extract attributes from. + * + * @return + * A Map of String,String representing the attributes and values + * from the OpenID provider point of view, or an empty Map if + * claim is not valid or the attributes claim type is missing. + * + * @throws GuacamoleException + * If guacamole.properties could not be parsed. + */ + public Map processAttributes(JwtClaims claims) throws GuacamoleException { + Collection attributesClaim = confService.getAttributesClaimType(); + + if (claims != null && !attributesClaim.isEmpty()) { + try { + logger.debug("Iterating over attributes claim list : {}", attributesClaim); + + // We suppose all claims are resolved, so the hashmap is initialised to + // the size of the configuration list + Map tokens = new HashMap(attributesClaim.size()); + + // We iterate over the configured attributes + for (String key: attributesClaim) { + // Retrieve the corresponding claim + String oidcAttr = claims.getStringClaimValue(key); + + // We do have a matching claim and it is not empty + if (oidcAttr != null && !oidcAttr.isEmpty()) { + // append the prefixed claim value to the token map with its value + String tokenName = TokenName.canonicalize(key, OIDC_ATTRIBUTE_TOKEN_PREFIX); + tokens.put(tokenName, oidcAttr); + logger.debug("Claim {} found and set to {}", key, tokenName); + } + else { + // wanted attribute is not found in the claim + logger.debug("Claim {} not found in JWT.", key); + } + } + + // We did process all the expected claims + return Collections.unmodifiableMap(tokens); + } + catch (MalformedClaimException e) { + logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); + } + } + + // Could not retrieve attributes from JWT + logger.debug("Attributes claim not defined. Returning empty map."); + return Collections.emptyMap(); + } +} From 7a6a8753c2fe066c04ee24f048be0c9bdb14f9da Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 8 Apr 2026 19:52:11 +0200 Subject: [PATCH 05/14] Remove accidentally committed files --- .../AuthenticationProviderService.java.orig | 274 -------- ...penIDAuthenticationProviderModule.java.rej | 11 - .../conf/ConfigurationService.java.orig | 639 ------------------ .../token/TokenValidationService.java.orig | 430 ------------ 4 files changed, 1354 deletions(-) delete mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig delete mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej delete mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig delete mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig deleted file mode 100644 index 1513b9b18d..0000000000 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java.orig +++ /dev/null @@ -1,274 +0,0 @@ -/* - * 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.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.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.user.SSOAuthenticatedUser; -import org.apache.guacamole.form.Field; -import org.apache.guacamole.form.RedirectField; -import org.apache.guacamole.language.TranslatableMessage; -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; - -import org.apache.guacamole.net.auth.IdentifierGenerator; - -/** - * Service that authenticates Guacamole users by processing OpenID tokens. - */ -@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 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. - */ - @Inject - private ConfigurationService confService; - - /** - * Manager of active SAML authentication attempts. - */ - @Inject - private OpenIDAuthenticationSessionManager sessionManager; - - /** - * Service for validating and generating unique nonce values. - */ - @Inject - private NonceService nonceService; - - /** - * Service for validating received ID tokens. - */ - @Inject - private TokenValidationService tokenService; - - /** - * Provider for AuthenticatedUser objects. - */ - @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 { - - String username = null; - Set groups = null; - Map tokens = Collections.emptyMap(); - - logger.debug("OpenID authentication with {} flow (ID: {}, Secret: {}, PKCE: {})", - confService.getFlowType(), - confService.getClientID(), - confService.getClientSecret(), - confService.isPKCERequired()); - - if (confService.getFlowType().equals("implicit")) { - String token = credentials.getParameter(IMPLICIT_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); - } - } - } 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.validateCode(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(AUTH_SESSION_QUERY_PARAM, getLoginURI(), - new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING")) - })) - ); - } - - @Override - public URI getLoginURI() throws GuacamoleException { - if (confService.getFlowType().equals("implicit")) { - 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 { - UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) - .queryParam("scope", confService.getScope()) - .queryParam("response_type", CODE_TOKEN_PARAMETER_NAME) - .queryParam("client_id", confService.getClientID()) - .queryParam("redirect_uri", confService.getRedirectURI()); - - 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.getAuthenticationTimeout() * 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 - public URI getLogoutURI(String idToken) throws GuacamoleException { - - // If no logout endpoint is configured, return null - URI logoutEndpoint = confService.getLogoutEndpoint(); - if (logoutEndpoint == null) - return null; - - // Build the logout URI with appropriate parameters - UriBuilder logoutUriBuilder = UriBuilder.fromUri(logoutEndpoint); - - // Add post_logout_redirect_uri parameter - logoutUriBuilder.queryParam("post_logout_redirect_uri", - confService.getPostLogoutRedirectURI()); - - // Add id_token_hint if available, otherwise add client_id - if (idToken != null && !idToken.isEmpty()) - logoutUriBuilder.queryParam("id_token_hint", idToken); - else - logoutUriBuilder.queryParam("client_id", confService.getClientID()); - - return logoutUriBuilder.build(); - } - - @Override - public void shutdown() { - sessionManager.shutdown(); - } - -} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej deleted file mode 100644 index 588486f9b8..0000000000 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java.rej +++ /dev/null @@ -1,11 +0,0 @@ ---- extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java 2026-04-08 19:37:41.518619970 +0200 -+++ extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java 2026-04-08 16:43:54.617087120 +0200 -@@ -37,7 +37,7 @@ - * The configuration environment for this server and extension. - */ - private final Environment environment = new OpenIDEnvironment(); -- -+ - @Override - protected void configure() { - bind(ConfigurationService.class); diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig deleted file mode 100644 index cf7bc1407e..0000000000 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/ConfigurationService.java.orig +++ /dev/null @@ -1,639 +0,0 @@ -/* - * 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 java.net.URI; -import java.util.Collection; -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; - -/** - * Service for retrieving configuration information regarding the OpenID - * service. - */ -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. - */ - private static final String DEFAULT_USERNAME_CLAIM_TYPE = "email"; - - /** - * The default claim type to use to retrieve an authenticated user's - * groups. - */ - private static final String DEFAULT_GROUPS_CLAIM_TYPE = "groups"; - - /** - * The default JWT claims list to map to tokens. - */ - private static final List DEFAULT_ATTRIBUTES_CLAIM_TYPE = Collections.emptyList(); - - /** - * The default space-separated list of OpenID scopes to request. - */ - private static final String DEFAULT_SCOPE = "openid email profile"; - - /** - * The default amount of clock skew tolerated for timestamp comparisons - * between the Guacamole server and OpenID service clocks, in seconds. - */ - private static final int DEFAULT_ALLOWED_CLOCK_SKEW = 30; - - /** - * The default maximum amount of time that an OpenID token should remain - * valid, in minutes. - */ - private static final int DEFAULT_MAX_TOKEN_VALIDITY = 300; - - /** - * The default maximum amount of time that a nonce generated by the - * Guacamole server should remain valid, in minutes. - */ - private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; - - /** - * The authorization endpoint (URI) of the OpenID service. - */ - private static final URIGuacamoleProperty OPENID_AUTHORIZATION_ENDPOINT = - new URIGuacamoleProperty() { - - @Override - public String getName() { return "openid-authorization-endpoint"; } - - }; - - /** - * The endpoint (URI) of the JWKS service which defines how received ID - * tokens (JWTs) shall be validated. - */ - private static final URIGuacamoleProperty OPENID_JWKS_ENDPOINT = - new URIGuacamoleProperty() { - - @Override - public String getName() { return "openid-jwks-endpoint"; } - - }; - - /** - * The issuer to expect for all received ID tokens. - */ - private static final StringGuacamoleProperty OPENID_ISSUER = - new StringGuacamoleProperty() { - - @Override - public String getName() { return "openid-issuer"; } - - }; - - /** - * 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. - */ - private static final StringGuacamoleProperty OPENID_USERNAME_CLAIM_TYPE = - new StringGuacamoleProperty() { - - @Override - public String getName() { return "openid-username-claim-type"; } - - }; - - /** - * The claim type which contains the authenticated user's groups within - * any valid JWT. - */ - private static final StringGuacamoleProperty OPENID_GROUPS_CLAIM_TYPE = - new StringGuacamoleProperty() { - - @Override - public String getName() { return "openid-groups-claim-type"; } - - }; - - /** - * The claims within any valid JWT that should be mapped to - * the authenticated user's tokens, as configured with guacamole.properties. - */ - private static final StringGuacamoleProperty OPENID_ATTRIBUTES_CLAIM_TYPE = - new StringGuacamoleProperty() { - @Override - public String getName() { return "openid-attributes-claim-type"; } - }; - - /** - * The space-separated list of OpenID scopes to request. - */ - private static final StringGuacamoleProperty OPENID_SCOPE = - new StringGuacamoleProperty() { - - @Override - public String getName() { return "openid-scope"; } - - }; - - /** - * The amount of clock skew tolerated for timestamp comparisons between the - * Guacamole server and OpenID service clocks, in seconds. - */ - private static final IntegerGuacamoleProperty OPENID_ALLOWED_CLOCK_SKEW = - new IntegerGuacamoleProperty() { - - @Override - public String getName() { return "openid-allowed-clock-skew"; } - - }; - - /** - * The maximum amount of time that an OpenID token should remain valid, in - * minutes. - */ - private static final IntegerGuacamoleProperty OPENID_MAX_TOKEN_VALIDITY = - new IntegerGuacamoleProperty() { - - @Override - public String getName() { return "openid-max-token-validity"; } - - }; - - /** - * The maximum amount of time that a nonce generated by the Guacamole server - * should remain valid, in minutes. As each OpenID request has a unique - * nonce value, this imposes an upper limit on the amount of time any - * particular OpenID request can result in successful authentication within - * Guacamole. - */ - private static final IntegerGuacamoleProperty OPENID_MAX_NONCE_VALIDITY = - new IntegerGuacamoleProperty() { - - @Override - public String getName() { return "openid-max-nonce-validity"; } - - }; - - /** - * OpenID client ID which should be submitted to the OpenID service when - * necessary. This value is typically provided by the OpenID service when - * OpenID credentials are generated for your application. - */ - private static final StringGuacamoleProperty OPENID_CLIENT_ID = - new StringGuacamoleProperty() { - - @Override - public String getName() { return "openid-client-id"; } - - }; - - /** - * The URI that the OpenID service should redirect to after the - * authentication process is complete. This must be the full URL that a - * user would enter into their browser to access Guacamole. - */ - private static final URIGuacamoleProperty OPENID_REDIRECT_URI = - new URIGuacamoleProperty() { - - @Override - public String getName() { return "openid-redirect-uri"; } - - }; - - /** - * The logout endpoint (URI) of the OpenID service. If specified, users - * will be redirected to this endpoint when they log out, allowing them - * to log out from the OpenID provider as well. - */ - private static final URIGuacamoleProperty OPENID_LOGOUT_ENDPOINT = - new URIGuacamoleProperty() { - - @Override - public String getName() { return "openid-logout-endpoint"; } - - }; - - /** - * The URI that the OpenID service should redirect to after logout is - * complete. If not specified, the main redirect URI will be used. - */ - private static final URIGuacamoleProperty OPENID_POST_LOGOUT_REDIRECT_URI = - new URIGuacamoleProperty() { - - @Override - public String getName() { return "openid-post-logout-redirect-uri"; } - - }; - - /** - * The Guacamole server environment. - */ - @Inject - private Environment environment; - - /** - * Returns the authorization endpoint (URI) of the OpenID service as - * configured with guacamole.properties. - * - * @return - * The authorization endpoint of the OpenID service, as configured with - * 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); - } - - /** - * Returns the OpenID client ID which should be submitted to the OpenID - * service when necessary, as configured with guacamole.properties. This - * value is typically provided by the OpenID service when OpenID credentials - * are generated for your application. - * - * @return - * The client ID to use when communicating with the OpenID service, - * as configured with guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the client ID - * property is missing. - */ - public String getClientID() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_CLIENT_ID); - } - - /** - * Returns the URI that the OpenID service should redirect to after - * the authentication process is complete, as configured with - * guacamole.properties. This must be the full URL that a user would enter - * into their browser to access Guacamole. - * - * @return - * The client secret to use when communicating with the OpenID service, - * as configured with guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the redirect URI - * property is missing. - */ - public URI getRedirectURI() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_REDIRECT_URI); - } - - /** - * Returns the issuer to expect for all received ID tokens, as configured - * with guacamole.properties. - * - * @return - * The issuer to expect for all received ID tokens, as configured with - * guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the issuer property - * is missing. - */ - public String getIssuer() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_ISSUER); - } - - /** - * Returns the endpoint (URI) of the JWKS service which defines how - * received ID tokens (JWTs) shall be validated, as configured with - * guacamole.properties. - * - * @return - * The endpoint (URI) of the JWKS service which defines how received ID - * tokens (JWTs) shall be validated, as configured with - * guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the JWKS endpoint - * property is missing. - */ - public URI getJWKSEndpoint() throws GuacamoleException { - 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 - * default, this will be "email". - * - * @return - * The claim type which contains the authenticated user's username - * within any valid JWT, as configured with guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public String getUsernameClaimType() throws GuacamoleException { - return environment.getProperty(OPENID_USERNAME_CLAIM_TYPE, DEFAULT_USERNAME_CLAIM_TYPE); - } - - /** - * Returns the claim type which contains the authenticated user's groups - * within any valid JWT, as configured with guacamole.properties. By - * default, this will be "groups". - * - * @return - * The claim type which contains the authenticated user's groups - * within any valid JWT, as configured with guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public String getGroupsClaimType() throws GuacamoleException { - return environment.getProperty(OPENID_GROUPS_CLAIM_TYPE, DEFAULT_GROUPS_CLAIM_TYPE); - } - - /** - * Returns the claims list within any valid JWT that should be mapped to - * the authenticated user's tokens, as configured with guacamole.properties. - * Empty by default. - * - * @return - * The claims list within any valid JWT that should be mapped to - * the authenticated user's tokens, as configured with guacamole.properties. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public Collection getAttributesClaimType() throws GuacamoleException { - return environment.getPropertyCollection(OPENID_ATTRIBUTES_CLAIM_TYPE, DEFAULT_ATTRIBUTES_CLAIM_TYPE); - } - - /** - * Returns the space-separated list of OpenID scopes to request. By default, - * this will be "openid email profile". The OpenID scopes determine the - * information returned within the OpenID token, and thus affect what - * values can be used as an authenticated user's username. - * - * @return - * The space-separated list of OpenID scopes to request when identifying - * a user. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public String getScope() throws GuacamoleException { - return environment.getProperty(OPENID_SCOPE, DEFAULT_SCOPE); - } - - /** - * Returns the amount of clock skew tolerated for timestamp comparisons - * between the Guacamole server and OpenID service clocks, in seconds. Too - * much clock skew will affect token expiration calculations, possibly - * allowing old tokens to be used. By default, this will be 30. - * - * @return - * The amount of clock skew tolerated for timestamp comparisons, in - * seconds. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public int getAllowedClockSkew() throws GuacamoleException { - return environment.getProperty(OPENID_ALLOWED_CLOCK_SKEW, DEFAULT_ALLOWED_CLOCK_SKEW); - } - - /** - * Returns the maximum amount of time that an OpenID token should remain - * valid, in minutes. A token received from an OpenID service which is - * older than this amount of time will be rejected, even if it is otherwise - * valid. By default, this will be 300 (5 hours). - * - * @return - * The maximum amount of time that an OpenID token should remain valid, - * in minutes. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public int getMaxTokenValidity() throws GuacamoleException { - return environment.getProperty(OPENID_MAX_TOKEN_VALIDITY, DEFAULT_MAX_TOKEN_VALIDITY); - } - - /** - * Returns the maximum amount of time that a nonce generated by the - * Guacamole server should remain valid, in minutes. As each OpenID request - * has a unique nonce value, 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 nonce generated by the Guacamole - * server should remain valid, in minutes. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public int getMaxNonceValidity() throws GuacamoleException { - return environment.getProperty(OPENID_MAX_NONCE_VALIDITY, DEFAULT_MAX_NONCE_VALIDITY); - } - - /** - * Returns the logout endpoint (URI) of the OpenID service, as configured - * with guacamole.properties. If configured, users will be redirected to - * this endpoint when they log out from Guacamole. - * - * @return - * The logout endpoint of the OpenID service, as configured with - * guacamole.properties, or null if not configured. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public URI getLogoutEndpoint() throws GuacamoleException { - return environment.getProperty(OPENID_LOGOUT_ENDPOINT); - } - - /** - * Returns the URI that the OpenID service should redirect to after logout - * is complete, as configured with guacamole.properties. If not configured, - * the main redirect URI will be used. - * - * @return - * The post-logout redirect URI, as configured with - * guacamole.properties, or the main redirect URI if not specified. - * - * @throws GuacamoleException - * If guacamole.properties cannot be parsed. - */ - public URI getPostLogoutRedirectURI() throws GuacamoleException { - return environment.getProperty(OPENID_POST_LOGOUT_REDIRECT_URI, getRedirectURI()); - } - -} diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig deleted file mode 100644 index fd9f4c16a2..0000000000 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java.orig +++ /dev/null @@ -1,430 +0,0 @@ -/* - * 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.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; -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.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; -import org.jose4j.jwt.consumer.InvalidJwtException; -import org.jose4j.jwt.consumer.JwtConsumer; -import org.jose4j.jwt.consumer.JwtConsumerBuilder; -import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Service for validating ID tokens forwarded to us by the client, verifying - * that they did indeed come from the OpenID service. - */ -public class TokenValidationService { - - /** - * Logger for this class. - */ - private final Logger logger = LoggerFactory.getLogger(TokenValidationService.class); - - /** - * The prefix to use when generating token names. - */ - public static final String OIDC_ATTRIBUTE_TOKEN_PREFIX = "OIDC_"; - - /** - * Service for retrieving OpenID configuration information. - */ - @Inject - private ConfigurationService confService; - - /** - * Service for validating and generating unique nonce values. - */ - @Inject - private NonceService nonceService; - - /** - * 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. - * - * @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 validateToken(String token) throws GuacamoleException { - // Validating the token requires a JWKS key resolver - HttpsJwks jwks = new HttpsJwks(confService.getJWKSEndpoint().toString()); - HttpsJwksVerificationKeyResolver resolver = new HttpsJwksVerificationKeyResolver(jwks); - - // 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 - 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; - } - else { - logger.info("Rejected OpenID token with invalid/old nonce."); - } - } - else { - logger.info("Rejected OpenID token without nonce."); - } - } - // Log any failures to validate/parse the JWT - catch (MalformedClaimException e) { - logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); - } - catch (InvalidJwtException e) { - logger.info("Rejected invalid OpenID token: {}", e.getMessage(), e); - } - - 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 verifier - * A PKCE verifier or null if not used. - * - * @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 validateCode(String code, String verifier) 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, verifier); - - // 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, 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)); - } - - // 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) { - 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 - * guacamole.properties. If the username claim type is missing or - * is invalid, null is returned. - * - * @param claims - * A valid JwtClaims to extract the username from. - * - * @return - * The username contained within the given JwtClaims, or null if the - * claim is not valid or the username claim type is missing, - * - * @throws GuacamoleException - * If guacamole.properties could not be parsed. - */ - public String processUsername(JwtClaims claims) throws GuacamoleException { - String usernameClaim = confService.getUsernameClaimType(); - - if (claims != null) { - try { - // Pull username from claims - String username = claims.getStringClaimValue(usernameClaim); - if (username != null) - return username; - } - catch (MalformedClaimException e) { - logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); - } - - // Warn if username was not present in token, as it likely means - // the system is not set up correctly - logger.warn("Username claim \"{}\" missing from token. Perhaps the " - + "OpenID scope and/or username claim type are " - + "misconfigured?", usernameClaim); - } - - // Could not retrieve username from JWT - return null; - } - - /** - * Parses the given JwtClaims, returning the groups contained - * therein, as defined by the groups claim type given in - * guacamole.properties. If the groups claim type is missing or - * is invalid, an empty set is returned. - * - * @param claims - * A valid JwtClaims to extract groups from. - * - * @return - * A Set of String representing the groups the user is member of - * from the OpenID provider point of view, or an empty Set if - * claim is not valid or the groups claim type is missing, - * - * @throws GuacamoleException - * If guacamole.properties could not be parsed. - */ - public Set processGroups(JwtClaims claims) throws GuacamoleException { - String groupsClaim = confService.getGroupsClaimType(); - - if (claims != null) { - try { - // Pull groups from claims - 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); - } - } - - // Could not retrieve groups from JWT - return Collections.emptySet(); - } - - /** - * Parses the given JwtClaims, returning the attributes contained - * therein, as defined by the attributes claim type given in - * guacamole.properties. If the attributes claim type is missing or - * is invalid, an empty set is returned. - * - * @param claims - * A valid JwtClaims to extract attributes from. - * - * @return - * A Map of String,String representing the attributes and values - * from the OpenID provider point of view, or an empty Map if - * claim is not valid or the attributes claim type is missing. - * - * @throws GuacamoleException - * If guacamole.properties could not be parsed. - */ - public Map processAttributes(JwtClaims claims) throws GuacamoleException { - Collection attributesClaim = confService.getAttributesClaimType(); - - if (claims != null && !attributesClaim.isEmpty()) { - try { - logger.debug("Iterating over attributes claim list : {}", attributesClaim); - - // We suppose all claims are resolved, so the hashmap is initialised to - // the size of the configuration list - Map tokens = new HashMap(attributesClaim.size()); - - // We iterate over the configured attributes - for (String key: attributesClaim) { - // Retrieve the corresponding claim - String oidcAttr = claims.getStringClaimValue(key); - - // We do have a matching claim and it is not empty - if (oidcAttr != null && !oidcAttr.isEmpty()) { - // append the prefixed claim value to the token map with its value - String tokenName = TokenName.canonicalize(key, OIDC_ATTRIBUTE_TOKEN_PREFIX); - tokens.put(tokenName, oidcAttr); - logger.debug("Claim {} found and set to {}", key, tokenName); - } - else { - // wanted attribute is not found in the claim - logger.debug("Claim {} not found in JWT.", key); - } - } - - // We did process all the expected claims - return Collections.unmodifiableMap(tokens); - } - catch (MalformedClaimException e) { - logger.info("Rejected OpenID token with malformed claim: {}", e.getMessage(), e); - } - } - - // Could not retrieve attributes from JWT - logger.debug("Attributes claim not defined. Returning empty map."); - return Collections.emptyMap(); - } -} From b2c99c9e5aba8246ca3ef522dffa0f8d96675439 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 9 Apr 2026 23:28:51 +0200 Subject: [PATCH 06/14] - Add openid-well-known-endpoint and treat it to automatically find the : * issuer, * authorization_endpoint, * token_endpoint, * jwks_uri - Replace openid-flow-type with openid-response-type - Use an Enum for openid-response-type to limit to the values 'id_token', 'token' or 'cod' - Treat the response type as an implicit flow type allowing use of AWS Cognito --- .../openid/AuthenticationProviderService.java | 33 ++- .../OpenIDAuthenticationProviderModule.java | 2 + .../openid/conf/ConfigurationService.java | 93 ++++++-- .../auth/openid/conf/OpenIDResponseType.java | 67 ++++++ .../auth/openid/conf/OpenIDWellKnown.java | 214 ++++++++++++++++++ .../openid/token/TokenValidationService.java | 49 +--- .../auth/openid/util/JsonUrlReader.java | 98 ++++++++ 7 files changed, 475 insertions(+), 81 deletions(-) create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDResponseType.java create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/conf/OpenIDWellKnown.java create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/util/JsonUrlReader.java 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 2647bbb917..5f5c8c45ca 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 @@ -63,6 +63,7 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS /** * 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"; @@ -136,13 +137,13 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) Set groups = null; Map tokens = Collections.emptyMap(); - logger.debug("OpenID authentication with {} flow (ID: {}, Secret: {}, PKCE: {})", - confService.getFlowType(), + logger.debug("OpenID authentication with '{}' reponse type (ID: {}, Secret: {}, PKCE: {})", + confService.getResponseType(), confService.getClientID(), confService.getClientSecret(), confService.isPKCERequired()); - if (confService.getFlowType().equals("implicit")) { + if (confService.isImplicitFlow()) { String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); if (token != null) { JwtClaims claims = tokenService.validateToken(token); @@ -194,21 +195,15 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) @Override public URI getLoginURI() throws GuacamoleException { - if (confService.getFlowType().equals("implicit")) { - 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(); + UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) + .queryParam("scope", confService.getScope()) + .queryParam("response_type", confService.getResponseType().toString()) + .queryParam("client_id", confService.getClientID()) + .queryParam("redirect_uri", confService.getRedirectURI()); + + if (confService.isImplicitFlow()) { + builder.queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)); } else { - UriBuilder builder = UriBuilder.fromUri(confService.getAuthorizationEndpoint()) - .queryParam("scope", confService.getScope()) - .queryParam("response_type", CODE_TOKEN_PARAMETER_NAME) - .queryParam("client_id", confService.getClientID()) - .queryParam("redirect_uri", confService.getRedirectURI()); - if (confService.isPKCERequired()) { String codeVerifier = PKCEUtil.generateCodeVerifier(); String codeChallenge; @@ -230,9 +225,9 @@ public URI getLoginURI() throws GuacamoleException { .queryParam("code_challenge_method", "S256") .queryParam(AUTH_SESSION_QUERY_PARAM, identifier); } - - return builder.build(); } + + return builder.build(); } @Override 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 bbd5c4c348..a74832ab7f 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,10 @@ 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; 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 7e0fa78794..0dc6576a40 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 @@ -20,13 +20,17 @@ package org.apache.guacamole.auth.openid.conf; import com.google.inject.Inject; + import java.net.URI; 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; @@ -38,9 +42,9 @@ public class ConfigurationService { /** - * The default OICD flow type + * The default OICD reponse type */ - private static final String DEFAULT_FLOW_TYPE = "implicit"; + private static final OpenIDResponseType DEFAULT_RESPONSE_TYPE = OpenIDResponseType.ID_TOKEN; /** * The default claim type to use to retrieve an authenticated user's @@ -128,13 +132,13 @@ public String getName() { }; /** - * The flow type of the OpenID service. + * The reponse type of the OpenID service. */ - private static final StringGuacamoleProperty OPENID_FLOW_TYPE = - new StringGuacamoleProperty() { + private static final EnumGuacamoleProperty OPENID_RESPONSE_TYPE = + new EnumGuacamoleProperty(OpenIDResponseType.class) { @Override - public String getName() { return "openid-flow-type"; } + public String getName() { return "openid-response-type"; } }; @@ -316,6 +320,12 @@ public String getName() { */ @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 @@ -326,11 +336,15 @@ public String getName() { * 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; } /** @@ -382,7 +396,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; } /** @@ -400,7 +419,12 @@ 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; } /** @@ -412,25 +436,60 @@ public URI getJWKSEndpoint() throws GuacamoleException { * guacamole.properties. * * @throws GuacamoleException - * If guacamole.properties cannot be parsed, or if the authorization + * If guacamole.properties cannot be parsed, or if the token * endpoint property is missing. */ public URI getTokenEndpoint() throws GuacamoleException { - return environment.getRequiredProperty(OPENID_TOKEN_ENDPOINT); + 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 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 well-known + * endpoint property is missing. + */ + public URI getWellKnownEndpoint() throws GuacamoleException { + return confWellKnown.getWellKnownEndpoint(); + } + + /** + * 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 the flow type of the OpenID service as configured with guacamole.properties. + * Returns true if the response type defines an implict flow * * @return - * The flow type of the OpenID service, as configured with guacamole.properties. Can - * be either 'implicit' or 'code'. + * The whether implicit flow is used or not, as configured with guacamole.properties. * * @throws GuacamoleException * If guacamole.properties cannot be parsed. */ - public String getFlowType() throws GuacamoleException { - return environment.getProperty(OPENID_FLOW_TYPE, DEFAULT_FLOW_TYPE); + public boolean isImplicitFlow() throws GuacamoleException { + OpenIDResponseType response_type = environment.getProperty(OPENID_RESPONSE_TYPE, DEFAULT_RESPONSE_TYPE); + return response_type != OpenIDResponseType.CODE; } /** 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..108fd7ecca --- /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,214 @@ +/* + * 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 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; + + /** + * 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. + */ + public 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; + + /* + * Creates an OpenIDWellKnown class that reads the json from an OIDC + * well-known endpoint and saves these values for later use. Use Guice + * to ensure environment exists before initializing. + */ + public OpenIDWellKnown() { + } + + @Inject + public void init() { + // 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().toURL(), ""); + 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.debug("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 31bb4c3ee5..5837619626 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,11 +20,6 @@ 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; @@ -39,6 +34,7 @@ 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.util.JsonUrlReader; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.token.TokenName; import org.jose4j.json.JsonUtil; @@ -243,46 +239,9 @@ private String exchangeCode(String code, String verifier) throws GuacamoleExcept bodyBuilder.append("&").append(urlencode("code_verifier", verifier)); } - // 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()); - } + Map json = + JsonUrlReader.fetch("POST", confService.getTokenEndpoint().toURL(), + bodyBuilder.toString()); return (String) json.get("id_token"); 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..b24299865a --- /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,98 @@ +/* + * 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.BufferedReader; +import java.io.OutputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +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); + + private JsonUrlReader() {} + + public static Map fetch(String method, URL url, String body) throws GuacamoleException { + if (url == null) { + throw new GuacamoleException("JsonUrlReader : Missing URL"); + } + + try { + // Open connection, using HttpURLConnection + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + conn.setRequestMethod(method); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + + if (! method.equalsIgnoreCase("GET")) { + conn.setDoOutput(true); + try (OutputStream out = conn.getOutputStream()) { + byte [] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + out.write(bodyBytes, 0, bodyBytes.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(); + + logger.debug("Response body : {}", responseBody.toString()); + + Map json = JsonUtil.parseJson(responseBody.toString()); + + if (status < 200 || status >= 300) { + throw new GuacamoleException("(status: " + status + "): " + json.toString()); + } + + return json; + } catch (Exception e) { + throw new GuacamoleException("JsonUrlreader error : " + method + " : " + e.getMessage()); + } + } +} From 1d862e66944bb667db76571eae4e82b2af754064 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 10 Apr 2026 11:24:47 +0200 Subject: [PATCH 07/14] Treat review comments --- .../openid/AuthenticationProviderService.java | 10 +- .../OpenIDAuthenticationProviderModule.java | 1 + .../openid/OpenIDAuthenticationSession.java | 1 + .../openid/conf/ConfigurationService.java | 34 ++---- .../auth/openid/conf/OpenIDWellKnown.java | 45 +++++--- .../openid/token/TokenValidationService.java | 105 ++++++------------ .../auth/openid/util/JsonUrlReader.java | 38 +++++-- .../guacamole/auth/openid/util/PKCEUtil.java | 23 +++- 8 files changed, 133 insertions(+), 124 deletions(-) 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 5f5c8c45ca..bb61c8126e 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 @@ -146,14 +146,15 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) if (confService.isImplicitFlow()) { String token = credentials.getParameter(IMPLICIT_TOKEN_PARAMETER_NAME); if (token != null) { - JwtClaims claims = tokenService.validateToken(token); + JwtClaims claims = tokenService.validateTokenOrCode(token, ""); if (claims != null) { username = tokenService.processUsername(claims); groups = tokenService.processGroups(claims); tokens = tokenService.processAttributes(claims); } } - } else { + } + else { String verifier = null; if (confService.isPKCERequired()) { // Recover session @@ -164,7 +165,7 @@ public SSOAuthenticatedUser authenticateUser(Credentials credentials) } String code = credentials.getParameter("code"); if (code != null && (confService.isPKCERequired() == false || verifier != null)) { - JwtClaims claims = tokenService.validateCode(code, verifier); + JwtClaims claims = tokenService.validateTokenOrCode(code, verifier); if (claims != null) { username = tokenService.processUsername(claims); groups = tokenService.processGroups(claims); @@ -203,7 +204,8 @@ public URI getLoginURI() throws GuacamoleException { if (confService.isImplicitFlow()) { builder.queryParam("nonce", nonceService.generate(confService.getMaxNonceValidity() * 60000L)); - } else { + } + else { if (confService.isPKCERequired()) { String codeVerifier = PKCEUtil.generateCodeVerifier(); String codeChallenge; 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 a74832ab7f..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 @@ -46,6 +46,7 @@ protected void configure() { 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 index 320f0609d2..92fa014ac0 100644 --- 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 @@ -24,6 +24,7 @@ /** * Representation of an in-progress OpenID authentication attempt. */ + public class OpenIDAuthenticationSession extends AuthenticationSession { /** * The PKCE challenge verifier. 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 0dc6576a40..f0727f3e49 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 @@ -20,7 +20,6 @@ package org.apache.guacamole.auth.openid.conf; import com.google.inject.Inject; - import java.net.URI; import java.util.Collection; import java.util.Collections; @@ -125,10 +124,9 @@ public class ConfigurationService { */ private static final URIGuacamoleProperty OPENID_TOKEN_ENDPOINT = new URIGuacamoleProperty() { + @Override - public String getName() { - return "openid-token-endpoint"; - } + public String getName() { return "openid-token-endpoint"; } }; /** @@ -148,10 +146,9 @@ public String getName() { */ private static final StringGuacamoleProperty OPENID_CLIENT_SECRET = new StringGuacamoleProperty() { + @Override - public String getName() { - return "openid-client-secret"; - } + public String getName() { return "openid-client-secret"; } }; /** @@ -159,10 +156,9 @@ public String getName() { */ private static final BooleanGuacamoleProperty OPENID_PKCE_REQUIRED = new BooleanGuacamoleProperty() { + @Override - public String getName() { - return "openid-pkce-required"; - } + public String getName() { return "openid-pkce-required"; } }; /** @@ -174,9 +170,9 @@ public String getName() { */ private static final IntegerGuacamoleProperty OPENID_AUTH_TIMEOUT = new IntegerGuacamoleProperty() { + @Override public String getName() { return "openid-auth-timeout"; } - }; @@ -447,22 +443,6 @@ public URI getTokenEndpoint() throws GuacamoleException { } return token_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 well-known - * endpoint property is missing. - */ - public URI getWellKnownEndpoint() throws GuacamoleException { - return confWellKnown.getWellKnownEndpoint(); - } /** * Returns the reponse type of the OpenID service as configured with guacamole.properties. 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 index 108fd7ecca..db61639d08 100644 --- 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 @@ -46,7 +46,7 @@ public class OpenIDWellKnown { /** * Logger for this class. */ - private final Logger logger = LoggerFactory.getLogger(OpenIDWellKnown.class); + private static final Logger logger = LoggerFactory.getLogger(OpenIDWellKnown.class); /** @@ -62,32 +62,37 @@ public class OpenIDWellKnown { /** * The detected issuer */ - private static String issuer = null; + private static String issuer = null; /** * The detected authorization edpoint */ - private static URI authorization_endpoint = null; + private static URI authorization_endpoint = null; /** * The detected token edpoint */ - private static URI token_endpoint = null; + private static URI token_endpoint = null; /** * The detected jwks_uri */ - private static URI jwks_uri = null; + 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"; - } + public String getName() { return "openid-well-known-endpoint"; } }; /** @@ -102,7 +107,7 @@ public String getName() { * If guacamole.properties cannot be parsed, or if the authorization * endpoint property is missing. */ - public URI getWellKnownEndpoint() throws GuacamoleException { + private URI getWellKnownEndpoint() throws GuacamoleException { return environment.getProperty(OPENID_WELL_KNOWN_ENDPOINT); } @@ -163,15 +168,22 @@ public URI getJWKSEndpoint() { private Environment environment; /* - * Creates an OpenIDWellKnown class that reads the json from an OIDC - * well-known endpoint and saves these values for later use. Use Guice - * to ensure environment exists before initializing. + * 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. */ - public OpenIDWellKnown() { - } - @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(); @@ -198,7 +210,8 @@ public void run() { scheduler.shutdown(); return; - } catch (Exception e) { + } + catch (Exception e) { logger.debug("Rejecting well-known endpoint : {}", e.getMessage()); } 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 5837619626..7a7d83f17c 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 @@ -30,7 +30,6 @@ import java.util.List; import java.util.Map; import java.util.Set; - import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.openid.conf.ConfigurationService; @@ -77,20 +76,26 @@ public class TokenValidationService { private NonceService nonceService; /** - * Validates the given ID token, using implicit flow, 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); @@ -105,26 +110,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 @@ -138,54 +153,6 @@ public JwtClaims validateToken(String token) throws GuacamoleException { 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 verifier - * A PKCE verifier or null if not used. - * - * @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 validateCode(String code, String verifier) 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, verifier); - - // 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 * @@ -211,6 +178,7 @@ private String urlencode(String key, String value) { * * @param code * The authorization code received from the IdP. + * * @param codeVerifier * The PKCE verifier (or null if PKCE is disabled). * @@ -245,7 +213,8 @@ private String exchangeCode(String code, String verifier) throws GuacamoleExcept return (String) json.get("id_token"); - } catch (Exception e) { + } + catch (Exception e) { logger.info("Rejected invalid OpenID code exchange: {}", e.getMessage(), e); } return null; @@ -318,7 +287,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 index b24299865a..9610330673 100644 --- 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 @@ -20,13 +20,13 @@ package org.apache.guacamole.auth.openid.util; import java.io.BufferedReader; +import java.io.IOException; import java.io.OutputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Map; -import org.apache.guacamole.GuacamoleException; import org.jose4j.json.JsonUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,17 +35,38 @@ * 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() {} - public static Map fetch(String method, URL url, String body) throws GuacamoleException { - if (url == null) { - throw new GuacamoleException("JsonUrlReader : Missing URL"); + /** + * 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 URL url + * A URL 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, URL url, String body) throws IOException { + if (url == null || url.toString() == "") { + throw new IOException("JsonUrlReader : Missing URL"); } try { @@ -55,7 +76,7 @@ public static Map fetch(String method, URL url, String body) thro conn.setRequestMethod(method); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); - if (! method.equalsIgnoreCase("GET")) { + if (! method.equals("GET")) { conn.setDoOutput(true); try (OutputStream out = conn.getOutputStream()) { byte [] bodyBytes = body.getBytes(StandardCharsets.UTF_8); @@ -87,12 +108,13 @@ public static Map fetch(String method, URL url, String body) thro Map json = JsonUtil.parseJson(responseBody.toString()); if (status < 200 || status >= 300) { - throw new GuacamoleException("(status: " + status + "): " + json.toString()); + throw new IOException("(status: " + status + "): " + json.toString()); } return json; - } catch (Exception e) { - throw new GuacamoleException("JsonUrlreader error : " + method + " : " + e.getMessage()); + } + 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 index a92876d95b..8257a8a3c6 100644 --- 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 @@ -30,13 +30,21 @@ * - 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]; @@ -46,6 +54,13 @@ public static String generateCodeVerifier() { /** * 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"); @@ -55,6 +70,12 @@ public static String generateCodeChallenge(String verifier) throws Exception { /** * 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() From 164f52811dd2335e14d85bc399c7b37d972e66c8 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 10 Apr 2026 13:30:14 +0200 Subject: [PATCH 08/14] Remove some no longer used imports for TokenValidationService --- .../guacamole/auth/openid/token/TokenValidationService.java | 3 --- 1 file changed, 3 deletions(-) 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 7a7d83f17c..27229e18a0 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 @@ -21,7 +21,6 @@ import com.google.inject.Inject; import java.net.URLEncoder; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; @@ -30,13 +29,11 @@ import java.util.List; import java.util.Map; import java.util.Set; -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.util.JsonUrlReader; 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; From d642ae5357a3606c5ad6a9fcfadc579825c9870e Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 10 Apr 2026 13:34:08 +0200 Subject: [PATCH 09/14] Print detected OIDC well-known information at debug-level equals info --- .../guacamole/auth/openid/conf/OpenIDWellKnown.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index db61639d08..f7e266babf 100644 --- 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 @@ -201,12 +201,12 @@ public void run() { token_endpoint = UriBuilder.fromUri((String) json.get("token_endpoint")).build(); jwks_uri = UriBuilder.fromUri((String) json.get("jwks_uri")).build(); - logger.debug("OIDC well-known\n" + - " issuer : {}\n" + - " authorization_endpoint : {}\n" + - " token_endpoint : {}\n" + - " jwks_uri : {}\n", - issuer, authorization_endpoint, token_endpoint, jwks_uri); + 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; From 5289f5dc474a2f817d9b88d3e6d16820145d6407 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 10 Apr 2026 19:04:21 +0200 Subject: [PATCH 10/14] Replace use of java.net.HttpURLConnection with jav.net.http.HttpClient, make the outboud url connection asynchronous --- .../auth/openid/conf/OpenIDWellKnown.java | 2 +- .../openid/token/TokenValidationService.java | 2 +- .../auth/openid/util/JsonUrlReader.java | 73 +++++++------------ 3 files changed, 29 insertions(+), 48 deletions(-) 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 index f7e266babf..e74d4c1024 100644 --- 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 @@ -195,7 +195,7 @@ public void run() { attempts++; try { - Map json = JsonUrlReader.fetch("GET", getWellKnownEndpoint().toURL(), ""); + 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(); 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 27229e18a0..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 @@ -205,7 +205,7 @@ private String exchangeCode(String code, String verifier) throws GuacamoleExcept } Map json = - JsonUrlReader.fetch("POST", confService.getTokenEndpoint().toURL(), + JsonUrlReader.fetch("POST", confService.getTokenEndpoint(), bodyBuilder.toString()); return (String) json.get("id_token"); 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 index 9610330673..8e3cd48b57 100644 --- 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 @@ -19,18 +19,19 @@ package org.apache.guacamole.auth.openid.util; -import java.io.BufferedReader; import java.io.IOException; -import java.io.OutputStream; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; +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 @@ -54,8 +55,8 @@ private JsonUrlReader() {} * @param String method * The htpp method to use. Should be "GET", "POST" or "PATCH". * - * @param URL url - * A URL value giving the address where to recover the JSON + * @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 @@ -64,48 +65,28 @@ private JsonUrlReader() {} * @return * A Map containing the decoded json values. */ - public static Map fetch(String method, URL url, String body) throws IOException { - if (url == null || url.toString() == "") { - throw new IOException("JsonUrlReader : Missing URL"); + 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 { - // Open connection, using HttpURLConnection - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - - conn.setRequestMethod(method); - conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); - - if (! method.equals("GET")) { - conn.setDoOutput(true); - try (OutputStream out = conn.getOutputStream()) { - byte [] bodyBytes = body.getBytes(StandardCharsets.UTF_8); - out.write(bodyBytes, 0, bodyBytes.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(); + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .method(method, + HttpRequest.BodyPublishers.ofString(body == null ? "" : body, StandardCharsets.UTF_8)) + .build(); - logger.debug("Response body : {}", responseBody.toString()); + // Asynchronous, non-blocking send, so that java servlet not blocked by outbound connection + CompletableFuture> future = + client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - Map json = JsonUtil.parseJson(responseBody.toString()); + 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()); @@ -114,7 +95,7 @@ public static Map fetch(String method, URL url, String body) thro return json; } catch (Exception e) { - throw new IOException("JsonUrlreader error : " + e.getMessage()); + throw new IOException("JsonUrlReader error: " + e.getMessage()); } } } From 38ea9becb81153d2d9487536c727d7f6e95905fc Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 13 Apr 2026 11:13:52 +0200 Subject: [PATCH 11/14] No body in JsonUrlReader for GET method --- .../auth/openid/util/JsonUrlReader.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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 index 8e3cd48b57..ef6b28fcc5 100644 --- 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 @@ -72,16 +72,21 @@ public static Map fetch(String method, URI uri, String body) thr try { HttpClient client = HttpClient.newHttpClient(); - HttpRequest request = HttpRequest.newBuilder() - .uri(uri) - .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") - .method(method, - HttpRequest.BodyPublishers.ofString(body == null ? "" : body, StandardCharsets.UTF_8)) - .build(); + 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 java servlet not blocked by outbound connection - CompletableFuture> future = - client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + // 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(); From 29c3ce14518c6908bfda57dc09582ab59db6bb14 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 13 Apr 2026 14:54:31 +0200 Subject: [PATCH 12/14] Replace openid-auth-timeout with openid-max-pkce-verifier-validity for consistency with openid-max-nonce-validity --- .../openid/AuthenticationProviderService.java | 2 +- .../openid/conf/ConfigurationService.java | 43 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) 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 bb61c8126e..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 @@ -219,7 +219,7 @@ public URI getLoginURI() throws GuacamoleException { // Store verifier for authenticateUser OpenIDAuthenticationSession session = new OpenIDAuthenticationSession(codeVerifier, - confService.getAuthenticationTimeout() * 60000L); + confService.getMaxPKCEVerifierValidity() * 60000L); String identifier = IdentifierGenerator.generateIdentifier(); sessionManager.defer(session, identifier); 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 f0727f3e49..a547c883b1 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 @@ -85,6 +85,12 @@ public class ConfigurationService { */ private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; + /** + * The default maximum amount of time that a nonce 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. */ @@ -162,19 +168,20 @@ public class ConfigurationService { }; /** - * 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. + * 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_AUTH_TIMEOUT = + private static final IntegerGuacamoleProperty OPENID_MAX_PKCE_VERIFIER_VALIDITY = new IntegerGuacamoleProperty() { @Override - public String getName() { return "openid-auth-timeout"; } - }; + public String getName() { return "openid-max-pkce-verifier-validity"; } + }; /** * The claim type which contains the authenticated user's username within @@ -508,21 +515,21 @@ public boolean isPKCERequired() throws GuacamoleException { } /** - * 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. + * 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 to allow for an in-progress SAML - * authentication attempt to be completed, in minutes. + * The maximum amount of time that a PKCE verifier generated by the + * Guacamole server should remain valid, in minutes. * * @throws GuacamoleException - * If the authentication timeout cannot be parsed. + * If guacamole.properties cannot be parsed. */ - public int getAuthenticationTimeout() throws GuacamoleException { - return environment.getProperty(OPENID_AUTH_TIMEOUT, 5); + public int getMaxPKCEVerifierValidity() throws GuacamoleException { + return environment.getProperty(OPENID_MAX_PKCE_VERIFIER_VALIDITY, DEFAULT_MAX_PKCE_VERIFIER_VALIDITY); } /** From 5e75d7c81adcdfbc9d1888ec2f3f27b20e91ee75 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 13 Apr 2026 15:11:00 +0200 Subject: [PATCH 13/14] Copy/Paste editing error --- .../apache/guacamole/auth/openid/conf/ConfigurationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a547c883b1..4bdcb267cf 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 @@ -80,7 +80,7 @@ public class ConfigurationService { private static final int DEFAULT_MAX_TOKEN_VALIDITY = 300; /** - * The default maximum amount of time that a nonce generated by the + * The default maximum amount of time that a pkce verifer generated by the * Guacamole server should remain valid, in minutes. */ private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; From 5f95b8dd54eb8c22c9886dce3343d9bac808a412 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 13 Apr 2026 15:22:43 +0200 Subject: [PATCH 14/14] Stupid cut/paste/edit mistake --- .../guacamole/auth/openid/conf/ConfigurationService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4bdcb267cf..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 @@ -80,13 +80,13 @@ public class ConfigurationService { private static final int DEFAULT_MAX_TOKEN_VALIDITY = 300; /** - * The default maximum amount of time that a pkce verifer generated by the + * The default maximum amount of time that a nonce generated by the * Guacamole server should remain valid, in minutes. */ private static final int DEFAULT_MAX_NONCE_VALIDITY = 10; /** - * The default maximum amount of time that a nonce generated by the + * 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;