From 393cc7746ba87bff88795c727e641e71421fbf17 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Wed, 29 Sep 2021 09:23:08 +0200 Subject: [PATCH 1/7] Initial version of the OpenId Connect implementation --- .../openid/OpenIdAuthenticationMechanism.java | 365 +++++++++++++++++ .../soteria/openid/OpenIdCredential.java | 78 ++++ .../soteria/openid/OpenIdExtension.java | 161 ++++++++ .../soteria/openid/OpenIdIdentityStore.java | 164 ++++++++ .../glassfish/soteria/openid/OpenIdState.java | 101 +++++ .../AccessTokenClaimsSetVerifier.java | 103 +++++ .../controller/AuthenticationController.java | 144 +++++++ .../soteria/openid/controller/CacheKey.java | 48 +++ .../controller/ConfigurationController.java | 370 ++++++++++++++++++ .../controller/IdTokenClaimsSetVerifier.java | 83 ++++ .../openid/controller/JWTValidator.java | 142 +++++++ .../openid/controller/NonceController.java | 93 +++++ .../ProviderMetadataController.java | 104 +++++ .../RefreshedIdTokenClaimsSetVerifier.java | 85 ++++ .../openid/controller/StateController.java | 74 ++++ .../controller/TokenClaimsSetVerifier.java | 167 ++++++++ .../openid/controller/TokenController.java | 202 ++++++++++ .../openid/controller/UserInfoController.java | 121 ++++++ .../openid/domain/AccessTokenImpl.java | 141 +++++++ .../openid/domain/ClaimsConfiguration.java | 54 +++ .../openid/domain/IdentityTokenImpl.java | 102 +++++ .../soteria/openid/domain/JsonClaims.java | 135 +++++++ .../openid/domain/LogoutConfiguration.java | 89 +++++ .../openid/domain/NimbusJwtClaims.java | 115 ++++++ .../openid/domain/OpenIdConfiguration.java | 248 ++++++++++++ .../openid/domain/OpenIdContextImpl.java | 213 ++++++++++ .../soteria/openid/domain/OpenIdNonce.java | 111 ++++++ .../openid/domain/OpenIdProviderData.java | 172 ++++++++ .../openid/domain/RefreshTokenImpl.java | 46 +++ .../soteria/openid/http/CookieController.java | 82 ++++ .../openid/http/HttpStorageController.java | 63 +++ .../openid/http/SessionController.java | 66 ++++ .../jakarta.enterprise.inject.spi.Extension | 1 + pom.xml | 14 +- 34 files changed, 4256 insertions(+), 1 deletion(-) create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/OpenIdAuthenticationMechanism.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/OpenIdCredential.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/OpenIdState.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/AccessTokenClaimsSetVerifier.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/AuthenticationController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/CacheKey.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/ConfigurationController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/IdTokenClaimsSetVerifier.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/JWTValidator.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/NonceController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/ProviderMetadataController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/RefreshedIdTokenClaimsSetVerifier.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/StateController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/TokenClaimsSetVerifier.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/TokenController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/controller/UserInfoController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/AccessTokenImpl.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/ClaimsConfiguration.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/IdentityTokenImpl.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/JsonClaims.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/LogoutConfiguration.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/NimbusJwtClaims.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdConfiguration.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdContextImpl.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdNonce.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdProviderData.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/domain/RefreshTokenImpl.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/http/CookieController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/http/HttpStorageController.java create mode 100644 impl/src/main/java/org/glassfish/soteria/openid/http/SessionController.java diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdAuthenticationMechanism.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdAuthenticationMechanism.java new file mode 100644 index 0000000..7455d44 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdAuthenticationMechanism.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid; + + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonNumber; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.security.auth.message.callback.CallerPrincipalCallback; +import jakarta.security.enterprise.AuthenticationException; +import jakarta.security.enterprise.AuthenticationStatus; +import jakarta.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism; +import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext; +import jakarta.security.enterprise.identitystore.CredentialValidationResult; +import jakarta.security.enterprise.identitystore.IdentityStoreHandler; +import jakarta.security.enterprise.identitystore.openid.RefreshToken; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.controller.AuthenticationController; +import org.glassfish.soteria.openid.controller.StateController; +import org.glassfish.soteria.openid.controller.TokenController; +import org.glassfish.soteria.openid.domain.LogoutConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdContextImpl; +import org.glassfish.soteria.openid.domain.RefreshTokenImpl; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import java.io.IOException; +import java.io.Serializable; +import java.io.StringReader; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static jakarta.security.enterprise.AuthenticationStatus.*; +import static jakarta.security.enterprise.identitystore.CredentialValidationResult.INVALID_RESULT; +import static jakarta.security.enterprise.identitystore.CredentialValidationResult.NOT_VALIDATED_RESULT; +import static jakarta.security.enterprise.identitystore.openid.OpenIdConstant.*; +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; + +/** + * The AuthenticationMechanism used to authenticate users using the OpenId + * Connect protocol + *
+ * Specification Implemented : + * http://openid.net/specs/openid-connect-core-1_0.html + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +// +--------+ +--------+ +// | | | | +// | |---------------(1) Authentication Request------------->| | +// | | | | +// | | +--------+ | | +// | | | End- |<--(2) Authenticates the End-User---->| | +// | RP | | User | | OP | +// | | +--------+ | | +// | | | | +// | |<---------(3) returns Authorization code---------------| | +// | | | | +// | | | | +// | |------------------------------------------------------>| | +// | | (4) Request to TokenEndpoint for Access / Id Token | | +// | OpenId |<------------------------------------------------------| OpenId | +// | Connect| | Connect| +// | Client | ----------------------------------------------------->|Provider| +// | | (5) Fetch JWKS to validate ID Token | | +// | |<------------------------------------------------------| | +// | | | | +// | |------------------------------------------------------>| | +// | | (6) Request to UserInfoEndpoint for End-User Claims | | +// | |<------------------------------------------------------| | +// | | | | +// +--------+ +--------+ +@ApplicationScoped +@Typed(OpenIdAuthenticationMechanism.class) +public class OpenIdAuthenticationMechanism implements HttpAuthenticationMechanism { + + @Inject + private OpenIdConfiguration configuration; + + @Inject + private OpenIdContextImpl context; + + private IdentityStoreHandler identityStoreHandler; + + @Inject + private AuthenticationController authenticationController; + + @Inject + private TokenController tokenController; + + @Inject + private StateController stateController; + + @Inject + Instance storeHandlerInstance; + + private static final Logger LOGGER = Logger.getLogger(OpenIdAuthenticationMechanism.class.getName()); + + private static class Lock implements Serializable { + } + + private static final String SESSION_LOCK_NAME = OpenIdAuthenticationMechanism.class.getName(); + + @PostConstruct + void init() { + if (storeHandlerInstance.isResolvable()) { + identityStoreHandler = storeHandlerInstance.get(); + return; + } + + throw new IllegalStateException("Cannot get instance of IdentityStoreHandler\n" + + "@Inject IdentityStoreHandler is unsatisfied."); + } + + @Override + public AuthenticationStatus validateRequest( + HttpServletRequest request, + HttpServletResponse response, + HttpMessageContext httpContext) throws AuthenticationException { + + if (isNull(request.getUserPrincipal())) { + LOGGER.fine("UserPrincipal is not set, authenticate user using OpenId Connect protocol."); + + // User is not authenticated + // Perform steps (1) to (6) + return this.authenticate(request, response, httpContext); + } else { + // User has been authenticated in request before + + // Try-catch-block taken from AutoApplySessionInterceptor + // We cannot use @AutoApplySession, because validateRequest(...) must be called on every request + // to handle re-authentication (refreshing tokens) + // https://stackoverflow.com/questions/51678821/soteria-httpmessagecontext-setregistersession-not-working-as-expected/51819055 + // https://github.com/javaee/security-soteria/blob/master/impl/src/main/java/org/glassfish/soteria/cdi/AutoApplySessionInterceptor.java + try { + httpContext.getHandler().handle(new Callback[]{ + new CallerPrincipalCallback(httpContext.getClientSubject(), request.getUserPrincipal())} + ); + } catch (IOException | UnsupportedCallbackException ex) { + throw new AuthenticationException("Failed to register CallerPrincipalCallback.", ex); + } + + LogoutConfiguration logout = configuration.getLogoutConfiguration(); + boolean accessTokenExpired = this.context.getAccessToken().isExpired(); + boolean identityTokenExpired = this.context.getIdentityToken().isExpired(); + if (logout.isIdentityTokenExpiry()) { + LOGGER.log(Level.FINE, "UserPrincipal is set, check if Identity Token is valid."); + } + if (logout.isAccessTokenExpiry()) { + LOGGER.log(Level.FINE, "UserPrincipal is set, check if Access Token is valid."); + } + + if ((accessTokenExpired || identityTokenExpired) && configuration.isTokenAutoRefresh()) { + if (accessTokenExpired) { + LOGGER.fine("Access Token is expired. Request new Access Token with Refresh Token."); + } + if (identityTokenExpired) { + LOGGER.fine("Identity Token is expired. Request new Identity Token with Refresh Token."); + } + return this.reAuthenticate(httpContext); + } else if ((logout.isAccessTokenExpiry() && accessTokenExpired) + || (logout.isIdentityTokenExpiry() && identityTokenExpired)) { + context.logout(request, response); + return SEND_FAILURE; + } else { + return SUCCESS; + } + } + } + + private AuthenticationStatus authenticate( + HttpServletRequest request, + HttpServletResponse response, + HttpMessageContext httpContext) { + + if (httpContext.isProtected() && isNull(request.getUserPrincipal())) { + // (1) The End-User is not authenticated. + return authenticationController.authenticateUser(request, response); + } + + Optional receivedState = OpenIdState.from(request.getParameter(STATE)); + String redirectURI = configuration.buildRedirectURI(request); + if (receivedState.isPresent()) { + if (!request.getRequestURL().toString().equals(redirectURI)) { + LOGGER.log(INFO, "OpenID Redirect URL {0} not matched with request URL {1}", new Object[]{redirectURI, request.getRequestURL().toString()}); + return httpContext.notifyContainerAboutLogin(NOT_VALIDATED_RESULT); + } + Optional expectedState = stateController.get(request, response); + if (!expectedState.isPresent()) { + LOGGER.fine("Expected state not found"); + return httpContext.notifyContainerAboutLogin(NOT_VALIDATED_RESULT); + } + if (!expectedState.equals(receivedState)) { + LOGGER.fine("Inconsistent received state, value not matched"); + return httpContext.notifyContainerAboutLogin(INVALID_RESULT); + } + // (3) Successful Authentication Response : redirect_uri?code=abc&state=123 + return validateAuthorizationCode(httpContext); + } + return httpContext.doNothing(); + } + + /** + * (3) & (4-6) An Authorization Code returned to Client (RP) via + * Authorization Code Flow must be validated and exchanged for an ID Token, + * an Access Token and optionally a Refresh Token directly. + * + * @param httpContext the {@link HttpMessageContext} to validate + * authorization code from + * @return the authentication status. + */ + private AuthenticationStatus validateAuthorizationCode(HttpMessageContext httpContext) { + HttpServletRequest request = httpContext.getRequest(); + HttpServletResponse response = httpContext.getResponse(); + String error = request.getParameter(ERROR_PARAM); + String errorDescription = request.getParameter(ERROR_DESCRIPTION_PARAM); + if (!Utils.isEmpty(error)) { + // Error responses sent to the redirect_uri + LOGGER.log(WARNING, "Error occurred in receiving Authorization Code : {0} caused by {1}", new Object[]{error, errorDescription}); + return httpContext.notifyContainerAboutLogin(INVALID_RESULT); + } + stateController.remove(request, response); + + LOGGER.finer("Authorization Code received, now fetching Access token & Id token"); + + Response tokenResponse = tokenController.getTokens(request); + JsonObject tokensObject = readJsonObject(tokenResponse.readEntity(String.class)); + if (tokenResponse.getStatus() == Status.OK.getStatusCode()) { + // Successful Token Response + updateContext(tokensObject); + OpenIdCredential credential = new OpenIdCredential(tokensObject, httpContext, configuration.getTokenMinValidity()); + CredentialValidationResult validationResult = identityStoreHandler.validate(credential); + + // Register session manually (if @AutoApplySession used, this would be done by its interceptor) + httpContext.setRegisterSession(validationResult.getCallerPrincipal().getName(), validationResult.getCallerGroups()); + return httpContext.notifyContainerAboutLogin(validationResult); + } else { + // Token Request is invalid or unauthorized + error = tokensObject.getString(ERROR_PARAM, "Unknown Error"); + errorDescription = tokensObject.getString(ERROR_DESCRIPTION_PARAM, "Unknown"); + LOGGER.log(WARNING, "Error occurred in validating Authorization Code : {0} caused by {1}", new Object[]{error, errorDescription}); + return httpContext.notifyContainerAboutLogin(INVALID_RESULT); + } + } + + private AuthenticationStatus reAuthenticate(HttpMessageContext httpContext) throws AuthenticationException { + HttpServletRequest request = httpContext.getRequest(); + HttpServletResponse response = httpContext.getResponse(); + synchronized (this.getSessionLock(httpContext.getRequest())) { + boolean accessTokenExpired = this.context.getAccessToken().isExpired(); + boolean identityTokenExpired = this.context.getIdentityToken().isExpired(); + if (accessTokenExpired || identityTokenExpired) { + + if (accessTokenExpired) { + LOGGER.fine("Access Token is expired. Request new Access Token with Refresh Token."); + } + if (identityTokenExpired) { + LOGGER.fine("Identity Token is expired. Request new Identity Token with Refresh Token."); + } + + AuthenticationStatus refreshStatus = this.context.getRefreshToken() + .map(rt -> this.refreshTokens(httpContext, rt)) + .orElse(AuthenticationStatus.SEND_FAILURE); + + if (refreshStatus != AuthenticationStatus.SUCCESS) { + LOGGER.log(Level.FINE, "Failed to refresh token (Refresh Token might be invalid)."); + context.logout(request, response); + } + return refreshStatus; + } + } + + return SUCCESS; + } + + private AuthenticationStatus refreshTokens(HttpMessageContext httpContext, RefreshToken refreshToken) { + Response response = tokenController.refreshTokens(refreshToken); + JsonObject tokensObject = readJsonObject(response.readEntity(String.class)); + + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + // Successful Token Response + updateContext(tokensObject); + OpenIdCredential credential = new OpenIdCredential(tokensObject, httpContext, configuration.getTokenMinValidity()); + CredentialValidationResult validationResult = identityStoreHandler.validate(credential); + + // Do not register session, as this will invalidate the currently active session (destroys session beans and removes attributes set in session)! + // httpContext.setRegisterSession(validationResult.getCallerPrincipal().getName(), validationResult.getCallerGroups()); + return httpContext.notifyContainerAboutLogin(validationResult); + } else { + // Token Request is invalid (refresh token invalid or expired) + String error = tokensObject.getString(ERROR_PARAM, "Unknown Error"); + String errorDescription = tokensObject.getString(ERROR_DESCRIPTION_PARAM, "Unknown"); + LOGGER.log(Level.FINE, "Error occurred in refreshing Access Token and Refresh Token : {0} caused by {1}", new Object[]{error, errorDescription}); + return AuthenticationStatus.SEND_FAILURE; + } + } + + private JsonObject readJsonObject(String tokensBody) { + try (JsonReader reader = Json.createReader(new StringReader(tokensBody))) { + return reader.readObject(); + } + } + + private void updateContext(JsonObject tokensObject) { + context.setTokenType(tokensObject.getString(TOKEN_TYPE, null)); + + String refreshToken = tokensObject.getString(REFRESH_TOKEN, null); + if (nonNull(refreshToken)) { + context.setRefreshToken(new RefreshTokenImpl(refreshToken)); + } + JsonNumber expiresIn = tokensObject.getJsonNumber(EXPIRES_IN); + if (nonNull(expiresIn)) { + context.setExpiresIn(expiresIn.longValue()); + } + } + + private Object getSessionLock(HttpServletRequest request) { + HttpSession session = request.getSession(); + Object lock = session.getAttribute(SESSION_LOCK_NAME); + if (isNull(lock)) { + synchronized (OpenIdAuthenticationMechanism.class) { + lock = session.getAttribute(SESSION_LOCK_NAME); + if (isNull(lock)) { + lock = new Lock(); + session.setAttribute(SESSION_LOCK_NAME, lock); + } + + } + } + return lock; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdCredential.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdCredential.java new file mode 100644 index 0000000..38e3862 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdCredential.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid; + + +import jakarta.json.JsonObject; +import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext; +import jakarta.security.enterprise.credential.Credential; +import jakarta.security.enterprise.identitystore.openid.AccessToken; +import org.glassfish.soteria.openid.domain.AccessTokenImpl; +import org.glassfish.soteria.openid.domain.IdentityTokenImpl; + +import static jakarta.security.enterprise.identitystore.openid.OpenIdConstant.*; +import static java.util.Objects.nonNull; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class OpenIdCredential implements Credential { + + private final HttpMessageContext httpContext; + + private final IdentityTokenImpl identityToken; + + private final AccessToken accessToken; + + public OpenIdCredential(JsonObject tokensObject, HttpMessageContext httpContext, long tokenMinValidity) { + this.httpContext = httpContext; + + this.identityToken = new IdentityTokenImpl(tokensObject.getString(IDENTITY_TOKEN), tokenMinValidity); + String accessTokenString = tokensObject.getString(ACCESS_TOKEN, null); + Long expiresIn = null; + if (nonNull(tokensObject.getJsonNumber(EXPIRES_IN))) { + expiresIn = tokensObject.getJsonNumber(EXPIRES_IN).longValue(); + } + String tokenType = tokensObject.getString(TOKEN_TYPE, null); + String scopeString = tokensObject.getString(SCOPE, null); + if (nonNull(accessTokenString)) { + accessToken = new AccessTokenImpl(tokenType, accessTokenString, expiresIn, scopeString, tokenMinValidity); + } else { + accessToken = null; + } + } + + /** + * Only for internal use within Soteria to be able to validate the token. + * + * @return Identity Token Implementation + */ + IdentityTokenImpl getIdentityTokenImpl() { + return identityToken; + } + + public AccessToken getAccessToken() { + return accessToken; + } + + public HttpMessageContext getHttpContext() { + return httpContext; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java new file mode 100644 index 0000000..f2c662d --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid; + + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.spi.*; +import jakarta.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism; +import jakarta.security.enterprise.identitystore.IdentityStore; +import jakarta.security.enterprise.identitystore.OpenIdAuthenticationDefinition; +import org.glassfish.soteria.openid.controller.*; +import org.glassfish.soteria.openid.domain.OpenIdContextImpl; + +import java.util.logging.Logger; + +import static java.util.logging.Level.INFO; + +/** + * Activates {@link OpenIdAuthenticationMechanism} with the + * {@link OpenIdAuthenticationDefinition} annotation configuration. + * + * @author Gaurav Gupta + * @author Patrik Duditš + * @author Rudy De Busscher + * + */ +public class OpenIdExtension implements Extension { + + private static final Logger LOGGER = Logger.getLogger(OpenIdExtension.class.getName()); + + private OpenIdAuthenticationDefinition definition; + + protected void registerTypes(@Observes BeforeBeanDiscovery before) { + registerTypes(before, + AuthenticationController.class, + ConfigurationController.class, + NonceController.class, + ProviderMetadataController.class, + StateController.class, + TokenController.class, + UserInfoController.class, + OpenIdContextImpl.class, + OpenIdIdentityStore.class, + OpenIdAuthenticationMechanism.class, + JWTValidator.class + ); + } + + private void registerTypes(BeforeBeanDiscovery event, Class... classes) { + for (Class aClass : classes) { + event.addAnnotatedType(aClass, aClass.getName()); + } + } + + /** + * Find the {@link OpenIdAuthenticationDefinition} annotation and validate. + */ + protected void findOpenIdDefinitionAnnotation(@Observes @WithAnnotations(OpenIdAuthenticationDefinition.class) ProcessAnnotatedType event) { + Class beanClass = event.getAnnotatedType().getJavaClass(); + OpenIdAuthenticationDefinition standardDefinition = event.getAnnotatedType().getAnnotation(OpenIdAuthenticationDefinition.class); + setDefinition(standardDefinition, beanClass); + } + + private void setDefinition(OpenIdAuthenticationDefinition definition, Class sourceClass) { + if (this.definition != null) { + LOGGER.warning("Multiple authentication definition found. Will ignore the definition in " + sourceClass); + return; + } + validateExtraParametersFormat(definition); + this.definition = definition; + LOGGER.log(INFO, "Activating OpenID Connect authentication definition from class {0}", + sourceClass.getName()); + } + + protected void validateExtraParametersFormat(OpenIdAuthenticationDefinition definition) { + for (String extraParameter : definition.extraParameters()) { + String[] parts = extraParameter.split("="); + if (parts.length != 2) { + throw new DefinitionException( + OpenIdAuthenticationDefinition.class.getSimpleName() + + ".extraParameters() value '" + extraParameter + + "' is not of the format key=value" + ); + } + } + } + + protected void registerDefinition(@Observes AfterBeanDiscovery afterBeanDiscovery, BeanManager beanManager) { + + LOGGER.log(INFO, "AfterBean Discovery {0}", + definition.getClass().getName()); + + if (definition != null) { + + // if definition is active we broaden the type of OpenIdAuthenticationMechanism back to + // HttpAuthenticationMechanism, so it would be picked up by Jakarta Security. + afterBeanDiscovery.addBean() + .beanClass(HttpAuthenticationMechanism.class) + .addType(HttpAuthenticationMechanism.class) + .id(OpenIdExtension.class.getName() + "/OpenIdAuthenticationMechanism") + .scope(ApplicationScoped.class) + .produceWith(in -> in.select(OpenIdAuthenticationMechanism.class).get()) + .disposeWith((inst, callback) -> callback.destroy(inst)); + + afterBeanDiscovery.addBean() + .beanClass(IdentityStore.class) + .addType(IdentityStore.class) + .id(OpenIdExtension.class.getName() + "/OpenIdIdentityStore") + .scope(ApplicationScoped.class) + .produceWith(in -> in.select(OpenIdIdentityStore.class).get()) + .disposeWith((inst, callback) -> callback.destroy(inst)); + + /* + afterBeanDiscovery.addBean() + .beanClass(OpenIdContextImpl.class) + .addType(OpenIdContext.class) + .id(OpenIdExtension.class.getName() + "/OpenIdContext") + .scope(SessionScoped.class) + .produceWith(in -> in.select(OpenIdContextImpl.class).get()) + .disposeWith((inst, callback) -> callback.destroy(inst)); + */ + + afterBeanDiscovery.addBean() + .beanClass(OpenIdAuthenticationDefinition.class) + .types(OpenIdAuthenticationDefinition.class) + .scope(ApplicationScoped.class) + .id("OpenId Definition") + .createWith(cc -> this.definition); + + + } else { + // Publish empty definition to prevent injection errors. The helper components will not work, but + // will not cause definition error. This is quite unlucky situation, but when definition is on an + // alternative bean we don't know before this moment whether the bean is enabled or not. + afterBeanDiscovery.addBean() + .beanClass(OpenIdAuthenticationDefinition.class) + .types(OpenIdAuthenticationDefinition.class) + .scope(Dependent.class) + .id("Null OpenId Definition") + .createWith(cc -> null); + } + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java new file mode 100644 index 0000000..c43b18b --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid; + +import com.nimbusds.jose.Algorithm; +import com.nimbusds.jwt.JWTClaimsSet; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext; +import jakarta.security.enterprise.credential.Credential; +import jakarta.security.enterprise.identitystore.CredentialValidationResult; +import jakarta.security.enterprise.identitystore.IdentityStore; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.controller.TokenController; +import org.glassfish.soteria.openid.domain.AccessTokenImpl; +import org.glassfish.soteria.openid.domain.IdentityTokenImpl; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdContextImpl; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +/** + * Identity store validates the identity token & access token and returns the + * validation result with the caller name and groups. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class OpenIdIdentityStore implements IdentityStore { + + private static final Logger LOGGER = Logger.getLogger(OpenIdIdentityStore.class.getName()); + + @Inject + private OpenIdContextImpl context; + + @Inject + private TokenController tokenController; + + @Inject + private OpenIdConfiguration configuration; + + @SuppressWarnings("unused") // IdentityStore calls overloads + public CredentialValidationResult validate(OpenIdCredential credential) { + HttpMessageContext httpContext = credential.getHttpContext(); + IdentityTokenImpl idToken = credential.getIdentityTokenImpl(); + + Algorithm idTokenAlgorithm = idToken.getTokenJWT().getHeader().getAlgorithm(); + + JWTClaimsSet idTokenClaims; + if (isNull(context.getIdentityToken())) { + idTokenClaims = tokenController.validateIdToken(idToken, httpContext); + } else { + // If an ID Token is returned as a result of a token refresh request + idTokenClaims = tokenController.validateRefreshedIdToken(context.getIdentityToken(), idToken); + } + context.setIdentityToken(idToken.withClaims(idTokenClaims)); + + AccessTokenImpl accessToken = (AccessTokenImpl) credential.getAccessToken(); + if (nonNull(accessToken)) { + tokenController.validateAccessToken( + accessToken, idTokenAlgorithm, context.getIdentityToken().getClaims() + ); + context.setAccessToken(accessToken); + } + + String callerName = getCallerName(); + context.setCallerName(callerName); + Set callerGroups = getCallerGroups(); + context.setCallerGroups(callerGroups); + + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.log(Level.FINE, "Setting caller groups into the OpenID context: " + callerGroups); + if (LOGGER.isLoggable(Level.FINER)) { + LOGGER.log(Level.FINER, "Setting caller name into the OpenID context: " + callerName); + } + } + + return new CredentialValidationResult( + context.getCallerName(), + context.getCallerGroups() + ); + } + + @Override + public CredentialValidationResult validate(Credential credential) { + if (credential instanceof OpenIdCredential) { + return validate((OpenIdCredential) credential); + } else { + return CredentialValidationResult.NOT_VALIDATED_RESULT; + } + } + + private String getCallerName() { + String callerNameClaim = configuration.getClaimsConfiguration().getCallerNameClaim(); + if (OpenIdConstant.SUBJECT_IDENTIFIER.equals(callerNameClaim)) { + return context.getSubject(); + } + String callerName = context.getIdentityToken().getJwtClaims().getStringClaim(callerNameClaim).orElse(null); + if (callerName == null) { + callerName = context.getAccessToken().getJwtClaims().getStringClaim(callerNameClaim).orElse(null); + } + if (callerName == null) { + callerName = context.getClaims().getStringClaim(callerNameClaim).orElse(null); + } + if (callerName == null) { + callerName = context.getSubject(); + } + return callerName; + } + + private Set getCallerGroups() { + String callerGroupsClaim = configuration.getClaimsConfiguration().getCallerGroupsClaim(); + + // Try CallerGroups from AccessToken + List groupsAccessClaim + = context.getAccessToken().getJwtClaims().getArrayStringClaim(callerGroupsClaim); + if (!groupsAccessClaim.isEmpty()) { + return new HashSet<>(groupsAccessClaim); + } + + // Try CallerGroups from IdentityToken + List groupsIdentityClaim + = context.getIdentityToken().getJwtClaims().getArrayStringClaim(callerGroupsClaim); + if (!groupsIdentityClaim.isEmpty()) { + return new HashSet<>(groupsIdentityClaim); + } + + // Try CallerGroups from info returned by /userinfo endpoint. + List groupsUserinfoClaim + = context.getClaims().getArrayStringClaim(callerGroupsClaim); + if (!groupsUserinfoClaim.isEmpty()) { + return new HashSet<>(groupsUserinfoClaim); + } + + // No luck, just empty set. + return Collections.emptySet(); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdState.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdState.java new file mode 100644 index 0000000..159829f --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdState.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid; + +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * Class to hold state of OpenId + *

+ * This is used in the authentication mechanism to both help prevent CSRF and to + * pass data to the callback page. + * + * @author Gaurav Gupta + * @author jonathan + * @author Rudy De Busscher + */ +public class OpenIdState implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String state; + + /** + * Creates a new instance with a random UUID as the state. + */ + public OpenIdState() { + state = UUID.randomUUID().toString(); + } + + /** + * Creates a new instance set the state to what is in the constructor. + *

+ * This can be used so that the callback page knows the originating page, + * but is not used by the + * {@link OpenIdAuthenticationMechanism} by default + * + * @param state the state to encapsulate + */ + public OpenIdState(String state) { + this.state = state; + } + + /** + * Factory method which creates an {@link OpenIdState} if the + * state provided is not NULL or empty. + * @param state the state to create an {@link OpenIdState} from + * @return an {@link OpenIdState} if the state provided is not NULL or empty + */ + public static Optional from(String state) { + if (state == null || "".equals(state.trim())) { + return Optional.empty(); + } + return Optional.of(new OpenIdState(state.trim())); + } + + /** + * Gets the state + * + * @return the state + */ + public String getValue() { + return state; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof OpenIdState) { + return Objects.equals(this.state, ((OpenIdState)obj).state); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.state); + } + + @Override + public String toString() { + return state; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/AccessTokenClaimsSetVerifier.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/AccessTokenClaimsSetVerifier.java new file mode 100644 index 0000000..b2de9c1 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/AccessTokenClaimsSetVerifier.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jose.Algorithm; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.BadJWTException; +import jakarta.security.enterprise.identitystore.openid.AccessToken; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.US_ASCII; + +/** + * Validates the Access token + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class AccessTokenClaimsSetVerifier extends TokenClaimsSetVerifier { + + private final AccessToken accessToken; + + private final Algorithm idTokenAlgorithm; + + private final Map idTokenClaims; + + public AccessTokenClaimsSetVerifier( + AccessToken accessToken, + Algorithm idTokenAlgorithm, + Map idTokenClaims, + OpenIdConfiguration configuration) { + super(configuration); + this.accessToken = accessToken; + this.idTokenAlgorithm = idTokenAlgorithm; + this.idTokenClaims = idTokenClaims; + } + + @Override + public void verify(JWTClaimsSet claims) throws BadJWTException { + validateAccessToken(); + } + + public void validateAccessToken() { + if (idTokenClaims.containsKey(OpenIdConstant.ACCESS_TOKEN_HASH)) { + + //Get the message digest for the JWS algorithm value used in the header(alg) of the ID Token + MessageDigest md = getMessageDigest(idTokenAlgorithm); + + // Hash the octets of the ASCII representation of the access_token with the hash algorithm + md.update(accessToken.toString().getBytes(US_ASCII)); + byte[] hash = md.digest(); + + // Take the left-most half of the hash and base64url encode it. + byte[] leftHalf = Arrays.copyOf(hash, hash.length / 2); + String accessTokenHash = Base64URL.encode(leftHalf).toString(); + + // The value of at_hash in the ID Token MUST match the value produced + if (!idTokenClaims.get(OpenIdConstant.ACCESS_TOKEN_HASH).equals(accessTokenHash)) { + throw new IllegalStateException("Invalid access token hash (at_hash) value"); + } + } + } + + /** + * Get the message digest instance for the given JWS algorithm value. + * + * @param algorithm The JSON Web Signature (JWS) algorithm. + * + * @return The message digest instance + */ + private MessageDigest getMessageDigest(Algorithm algorithm) { + String mdAlgorithm = "SHA-" + algorithm.getName().substring(2); + + try { + return MessageDigest.getInstance(mdAlgorithm); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("No MessageDigest instance found with the specified algorithm : " + mdAlgorithm, ex); + } + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/AuthenticationController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/AuthenticationController.java new file mode 100644 index 0000000..4f1ab11 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/AuthenticationController.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.security.enterprise.AuthenticationStatus; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.UriBuilder; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.OpenIdState; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdNonce; +import org.glassfish.soteria.openid.http.HttpStorageController; + +import java.io.IOException; +import java.util.logging.Logger; + +import static java.util.logging.Level.FINEST; +import static jakarta.security.enterprise.AuthenticationStatus.SEND_CONTINUE; + +/** + * Controller for Authentication endpoint + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class AuthenticationController { + + @Inject + private StateController stateController; + + @Inject + private NonceController nonceController; + + @Inject + private OpenIdConfiguration configuration; + + private static final Logger LOGGER = Logger.getLogger(AuthenticationController.class.getName()); + + /** + * (1) The RP (Client) sends a request to the OpenId Connect Provider (OP) + * to authenticates the End-User using the Authorization Code Flow and + * authorization Code is returned from the Authorization Endpoint. + *
+ * (2) Authorization Server authenticates the End-User, obtains End-User + * Consent/Authorization and sends the End-User back to the Client with an + * Authorization Code. + * + * + * @param request + * @param response + * @return + */ + public AuthenticationStatus authenticateUser( + HttpServletRequest request, + HttpServletResponse response) { + + /* + * Client prepares an authentication request and redirect to the + * Authorization Server. if query param value is invalid then OpenId + * Connect provider redirect to error page (hosted in OP domain). + */ + UriBuilder authRequest + = UriBuilder.fromUri(configuration.getProviderMetadata().getAuthorizationEndpoint()) + .queryParam(OpenIdConstant.SCOPE, configuration.getScopes()) + .queryParam(OpenIdConstant.RESPONSE_TYPE, configuration.getResponseType()) + .queryParam(OpenIdConstant.CLIENT_ID, configuration.getClientId()) + .queryParam(OpenIdConstant.REDIRECT_URI, configuration.buildRedirectURI(request)); + + OpenIdState state = new OpenIdState(); + authRequest.queryParam(OpenIdConstant.STATE, state.getValue()); + stateController.store(state, configuration, request, response); + + storeRequestURL(request, response); + + // add nonce for replay attack prevention + if (configuration.isUseNonce()) { + OpenIdNonce nonce = new OpenIdNonce(); + // use a cryptographic hash of the value as the nonce parameter + String nonceHash = nonceController.getNonceHash(nonce); + authRequest.queryParam(OpenIdConstant.NONCE, nonceHash); + nonceController.store(nonce, configuration, request, response); + + } + if (!Utils.isEmpty(configuration.getResponseMode())) { + authRequest.queryParam(OpenIdConstant.RESPONSE_MODE, configuration.getResponseMode()); + } + if (!Utils.isEmpty(configuration.getDisplay())) { + authRequest.queryParam(OpenIdConstant.DISPLAY, configuration.getDisplay()); + } + if (!Utils.isEmpty(configuration.getPrompt())) { + authRequest.queryParam(OpenIdConstant.PROMPT, configuration.getPrompt()); + } + + configuration.getExtraParameters().forEach(authRequest::queryParam); + + String authUrl = authRequest.toString(); + LOGGER.log(FINEST, "Redirecting for authentication to {0}", authUrl); + try { + response.sendRedirect(authUrl); + } catch (IOException e) { + throw new IllegalStateException(e); + } + return SEND_CONTINUE; + } + + private void storeRequestURL(HttpServletRequest request, + HttpServletResponse response) { + HttpStorageController storage = HttpStorageController.getInstance(configuration, request, response); + + storage.store(OpenIdConstant.ORIGINAL_REQUEST, getFullURL(request), null); + + } + + private String getFullURL(HttpServletRequest request) { + StringBuilder requestURL = new StringBuilder(request.getRequestURL().toString()); + String queryString = request.getQueryString(); + + if (queryString == null) { + return requestURL.toString(); + } else { + return requestURL.append('?').append(queryString).toString(); + } + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/CacheKey.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/CacheKey.java new file mode 100644 index 0000000..d5d16a7 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/CacheKey.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import java.util.Arrays; +import java.util.Objects; + +class CacheKey { + private final Object[] attributes; + private final int hashCode; + + CacheKey(Object... attributes) { + this.attributes = attributes; + this.hashCode = Objects.hash(attributes); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CacheKey that = (CacheKey) o; + return hashCode == that.hashCode && Arrays.equals(attributes, that.attributes); + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/ConfigurationController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/ConfigurationController.java new file mode 100644 index 0000000..2c8c30c --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/ConfigurationController.java @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.security.enterprise.identitystore.*; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.domain.ClaimsConfiguration; +import org.glassfish.soteria.openid.domain.LogoutConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdProviderData; + +import java.io.Serializable; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.*; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.joining; +import static org.glassfish.soteria.cdi.AnnotationELPProcessor.evalImmediate; + +/** + * Build and validate the OpenId Connect client configuration. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class ConfigurationController implements Serializable { + + @Inject + private ProviderMetadataController providerMetadataController; + + private static final String SPACE_SEPARATOR = " "; + + private volatile transient LastBuiltConfig lastBuiltConfig; + + @Produces + @RequestScoped + public OpenIdConfiguration produceConfiguration(OpenIdAuthenticationDefinition definition) { + if (lastBuiltConfig == null) { + lastBuiltConfig = new LastBuiltConfig(null, null); + } + OpenIdConfiguration cached = lastBuiltConfig.cachedConfiguration(definition); + if (cached != null) { + return cached; + } + OpenIdConfiguration config = buildConfig(definition); + lastBuiltConfig = new LastBuiltConfig(definition, config); + return config; + } + + /** + * Creates the {@link OpenIdConfiguration} using the properties as defined + * in an {@link OpenIdAuthenticationDefinition} annotation or using MP + * Config source. MP Config source value take precedence over + * {@link OpenIdAuthenticationDefinition} annotation value. + * + * @param definition + * @return + */ + public OpenIdConfiguration buildConfig(OpenIdAuthenticationDefinition definition) { + + String providerURI; + JsonObject providerDocument; + String authorizationEndpoint; + String tokenEndpoint; + String userinfoEndpoint; + String endSessionEndpoint; + String jwksURI; + URL jwksURL; + String issuer; + + providerURI = evalImmediate(definition.providerURI()); + OpenIdProviderMetadata providerMetadata = definition.providerMetadata(); + providerDocument = providerMetadataController.getDocument(providerURI); + + if (Utils.isEmpty(providerMetadata.authorizationEndpoint()) && providerDocument.containsKey(OpenIdConstant.AUTHORIZATION_ENDPOINT)) { + authorizationEndpoint = evalImmediate(providerDocument.getString(OpenIdConstant.AUTHORIZATION_ENDPOINT)); + } else { + authorizationEndpoint = evalImmediate(providerMetadata.authorizationEndpoint()); + } + if (Utils.isEmpty(providerMetadata.tokenEndpoint()) && providerDocument.containsKey(OpenIdConstant.TOKEN_ENDPOINT)) { + tokenEndpoint = evalImmediate(providerDocument.getString(OpenIdConstant.TOKEN_ENDPOINT)); + } else { + tokenEndpoint = evalImmediate(providerMetadata.tokenEndpoint()); + } + if (Utils.isEmpty(providerMetadata.userinfoEndpoint()) && providerDocument.containsKey(OpenIdConstant.USERINFO_ENDPOINT)) { + userinfoEndpoint = evalImmediate(providerDocument.getString(OpenIdConstant.USERINFO_ENDPOINT)); + } else { + userinfoEndpoint = evalImmediate(providerMetadata.userinfoEndpoint()); + } + if (Utils.isEmpty(providerMetadata.endSessionEndpoint()) && providerDocument.containsKey(OpenIdConstant.END_SESSION_ENDPOINT)) { + endSessionEndpoint = evalImmediate(providerDocument.getString(OpenIdConstant.END_SESSION_ENDPOINT)); + } else { + endSessionEndpoint = evalImmediate(providerMetadata.endSessionEndpoint()); + } + if (Utils.isEmpty(providerMetadata.jwksURI()) && providerDocument.containsKey(OpenIdConstant.JWKS_URI)) { + jwksURI = evalImmediate(providerDocument.getString(OpenIdConstant.JWKS_URI)); + } else { + jwksURI = evalImmediate(providerMetadata.jwksURI()); + } + try { + jwksURL = new URL(jwksURI); + } catch (MalformedURLException ex) { + throw new IllegalStateException("jwksURI is invalid", ex); + } + + if (Utils.isEmpty(providerMetadata.issuer()) && providerDocument.containsKey(OpenIdConstant.ISSUER)) { + issuer = evalImmediate(providerDocument.getString(OpenIdConstant.ISSUER)); + } else { + issuer = evalImmediate(providerMetadata.issuer()); + } + + List supportedResponseTypes = null; + if (providerDocument.containsKey(OpenIdConstant.RESPONSE_TYPES_SUPPORTED)) { + supportedResponseTypes = providerDocument.getJsonArray(OpenIdConstant.RESPONSE_TYPES_SUPPORTED).getValuesAs(JsonString::getString); + } + if (Utils.isEmpty(supportedResponseTypes)) { + String value = evalImmediate(providerMetadata.responseTypeSupported()); + supportedResponseTypes = Arrays.stream(value.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + } + + List supportedIdTokenSigningAlgorithms = null; + if (providerDocument.containsKey(OpenIdConstant.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED)) { + supportedIdTokenSigningAlgorithms = providerDocument.getJsonArray(OpenIdConstant.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED).getValuesAs(JsonString::getString); + } + if (Utils.isEmpty(supportedIdTokenSigningAlgorithms)) { + String value = evalImmediate(providerMetadata.idTokenSigningAlgorithmsSupported()); + supportedIdTokenSigningAlgorithms = Arrays.stream(value.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + } + + List supportedSubjectTypes = null; + if (providerDocument.containsKey(OpenIdConstant.SUBJECT_TYPES_SUPPORTED)) { + supportedSubjectTypes = providerDocument.getJsonArray(OpenIdConstant.SUBJECT_TYPES_SUPPORTED).getValuesAs(JsonString::getString); + } + if (Utils.isEmpty(supportedSubjectTypes)) { + String value = evalImmediate(providerMetadata.subjectTypeSupported()); + supportedSubjectTypes = Arrays.stream(value.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + } + + + String clientId = evalImmediate(definition.clientId()); + char[] clientSecret = evalImmediate(definition.clientSecret()).toCharArray(); + String redirectURI = evalImmediate(definition.redirectURI()); + + String scopes = String.join(SPACE_SEPARATOR, definition.scope()); + scopes = evalImmediate(definition.scopeExpression(), scopes); + if (Utils.isEmpty(scopes)) { + scopes = OpenIdConstant.OPENID_SCOPE; + } else if (!scopes.contains(OpenIdConstant.OPENID_SCOPE)) { + scopes = OpenIdConstant.OPENID_SCOPE + SPACE_SEPARATOR + scopes; + } + + String responseType = evalImmediate(definition.responseType()); + responseType + = Arrays.stream(responseType.trim().split(SPACE_SEPARATOR)) + .map(String::toLowerCase) + .sorted() + .collect(joining(SPACE_SEPARATOR)); + + String responseMode = evalImmediate(definition.responseMode()); + + String display = definition.display().toString().toLowerCase(); + display = evalImmediate(display); + + String prompt = Arrays.stream(definition.prompt()) + .map(PromptType::toString) + .map(String::toLowerCase) + .collect(joining(SPACE_SEPARATOR)); + prompt = evalImmediate(definition.promptExpression(), prompt); + + Map extraParameters = new HashMap<>(); + for (String extraParameter : definition.extraParameters()) { + String[] parts = extraParameter.split("="); + String key = parts[0]; + String value = parts[1]; + extraParameters.put(key, value); + } + + boolean nonce = evalImmediate(definition.useNonceExpression(), definition.useNonce()); + boolean session = evalImmediate(definition.useSessionExpression(), definition.useSession()); + + int jwksConnectTimeout = evalImmediate(definition.jwksConnectTimeoutExpression(), definition.jwksConnectTimeout()); + int jwksReadTimeout = evalImmediate(definition.jwksReadTimeoutExpression(), definition.jwksReadTimeout()); + + String callerNameClaim = evalImmediate(definition.claimsDefinition().callerNameClaim()); + String callerGroupsClaim = evalImmediate(definition.claimsDefinition().callerGroupsClaim()); + + boolean notifyProvider = evalImmediate(definition.logout().notifyProviderExpression(), definition.logout().notifyProvider()); + String logoutRedirectURI = evalImmediate(definition.logout().redirectURI()); + boolean accessTokenExpiry = evalImmediate(definition.logout().accessTokenExpiryExpression(), definition.logout().accessTokenExpiry()); + boolean identityTokenExpiry = evalImmediate(definition.logout().identityTokenExpiryExpression(), definition.logout().identityTokenExpiry()); + + boolean tokenAutoRefresh = evalImmediate(definition.tokenAutoRefreshExpression(), definition.tokenAutoRefresh()); + int tokenMinValidity = evalImmediate(definition.tokenMinValidityExpression(), definition.tokenMinValidity()); + + OpenIdConfiguration configuration = new OpenIdConfiguration() + .setProviderMetadata( + new OpenIdProviderData(providerDocument) + .setAuthorizationEndpoint(authorizationEndpoint) + .setTokenEndpoint(tokenEndpoint) + .setUserinfoEndpoint(userinfoEndpoint) + .setEndSessionEndpoint(endSessionEndpoint) + .setJwksURL(jwksURL) + .setIssuer(issuer) + .setResponseTypeSupported(new HashSet<>(supportedResponseTypes)) + .setIdTokenSigningAlgorithmsSupported(new HashSet<>(supportedIdTokenSigningAlgorithms)) + .setSubjectTypesSupported(new HashSet<>(supportedSubjectTypes)) + ) + .setClaimsConfiguration( + new ClaimsConfiguration() + .setCallerNameClaim(callerNameClaim) + .setCallerGroupsClaim(callerGroupsClaim) + ).setLogoutConfiguration( + new LogoutConfiguration() + .setNotifyProvider(notifyProvider) + .setRedirectURI(logoutRedirectURI) + .setAccessTokenExpiry(accessTokenExpiry) + .setIdentityTokenExpiry(identityTokenExpiry) + ) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setRedirectURI(redirectURI) + .setScopes(scopes) + .setResponseType(responseType) + .setResponseMode(responseMode) + .setExtraParameters(extraParameters) + .setPrompt(prompt) + .setDisplay(display) + .setUseNonce(nonce) + .setUseSession(session) + .setJwksConnectTimeout(jwksConnectTimeout) + .setJwksReadTimeout(jwksReadTimeout) + .setTokenAutoRefresh(tokenAutoRefresh) + .setTokenMinValidity(tokenMinValidity); + + validateConfiguration(configuration); + + return configuration; + } + + /** + * Validate the properties of the OpenId Connect Client and Provider + * Metadata + */ + private void validateConfiguration(OpenIdConfiguration configuration) { + List errorMessages = new ArrayList<>(); + errorMessages.addAll(validateProviderMetadata(configuration)); + errorMessages.addAll(validateClientConfiguration(configuration)); + + if (!errorMessages.isEmpty()) { + throw new IllegalStateException(errorMessages.toString()); + } + } + + private List validateProviderMetadata(OpenIdConfiguration configuration) { + List errorMessages = new ArrayList<>(); + + if (Utils.isEmpty(configuration.getProviderMetadata().getIssuerURI())) { + errorMessages.add("issuer metadata is mandatory"); + } + if (Utils.isEmpty(configuration.getProviderMetadata().getAuthorizationEndpoint())) { + errorMessages.add("authorization_endpoint metadata is mandatory"); + } + if (Utils.isEmpty(configuration.getProviderMetadata().getTokenEndpoint())) { + errorMessages.add("token_endpoint metadata is mandatory"); + } + if (configuration.getProviderMetadata().getJwksURL() == null) { + errorMessages.add("jwks_uri metadata is mandatory"); + } + if (configuration.getProviderMetadata().getResponseTypeSupported().isEmpty()) { + errorMessages.add("response_types_supported metadata is mandatory"); + } + if (configuration.getProviderMetadata().getSubjectTypesSupported().isEmpty()) { + errorMessages.add("subject_types_supported metadata is mandatory"); + } + if (configuration.getProviderMetadata().getIdTokenSigningAlgorithmsSupported().isEmpty()) { + errorMessages.add("id_token_signing_alg_values_supported metadata is mandatory"); + } + return errorMessages; + } + + private List validateClientConfiguration(OpenIdConfiguration configuration) { + List errorMessages = new ArrayList<>(); + + if (Utils.isEmpty(configuration.getClientId())) { + errorMessages.add("client_id request parameter is mandatory"); + } + if (Utils.isEmpty(configuration.getRedirectURI())) { + errorMessages.add("redirect_uri request parameter is mandatory"); + } + if (configuration.getJwksConnectTimeout() <= 0) { + errorMessages.add("jwksConnectTimeout value is not valid"); + } + if (configuration.getJwksReadTimeout() <= 0) { + errorMessages.add("jwksReadTimeout value is not valid"); + } + + if (Utils.isEmpty(configuration.getResponseType())) { + errorMessages.add("The response type must contain at least one value"); + } else if (!configuration.getProviderMetadata().getResponseTypeSupported().contains(configuration.getResponseType()) + && !OpenIdConstant.AUTHORIZATION_CODE_FLOW_TYPES.contains(configuration.getResponseType()) + && !OpenIdConstant.IMPLICIT_FLOW_TYPES.contains(configuration.getResponseType()) + && !OpenIdConstant.HYBRID_FLOW_TYPES.contains(configuration.getResponseType())) { + errorMessages.add("Unsupported OpenID Connect response type value : " + configuration.getResponseType()); + } + + Set supportedScopes = configuration.getProviderMetadata().getScopesSupported(); + if (!supportedScopes.isEmpty()) { + for (String scope : configuration.getScopes().split(SPACE_SEPARATOR)) { + if (!supportedScopes.contains(scope)) { + errorMessages.add(String.format( + "%s scope is not supported by %s OpenId Connect provider", + scope, + configuration.getProviderMetadata().getIssuerURI()) + ); + } + } + } + + return errorMessages; + } + + static class LastBuiltConfig { + private final OpenIdAuthenticationDefinition definition; + private final OpenIdConfiguration configuration; + + public LastBuiltConfig(OpenIdAuthenticationDefinition definition, OpenIdConfiguration configuration) { + this.definition = definition; + this.configuration = configuration; + } + + OpenIdConfiguration cachedConfiguration(OpenIdAuthenticationDefinition definition) { + if (this.definition != null && this.definition.equals(definition)) { + return configuration; + } + return null; + } + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/IdTokenClaimsSetVerifier.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/IdTokenClaimsSetVerifier.java new file mode 100644 index 0000000..ffcc277 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/IdTokenClaimsSetVerifier.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.BadJWTException; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import static java.util.Objects.isNull; + +/** + * Validates the ID token + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class IdTokenClaimsSetVerifier extends TokenClaimsSetVerifier { + + private final String expectedNonceHash; + + public IdTokenClaimsSetVerifier(String expectedNonceHash, OpenIdConfiguration configuration) { + super(configuration); + this.expectedNonceHash = expectedNonceHash; + } + + /** + * Validate ID Token's claims + * + * @param claims + * @throws com.nimbusds.jwt.proc.BadJWTException + */ + @Override + public void verify(JWTClaimsSet claims) throws BadJWTException { + + /* + * If a nonce was sent in the authentication request, a nonce claim must + * be present and its value checked to verify that it is the same value + * as the one that was sent in the authentication request to detect + * replay attacks. + */ + if (configuration.isUseNonce()) { + + final String nonce; + + try { + nonce = claims.getStringClaim(OpenIdConstant.NONCE); + } catch (java.text.ParseException ex) { + throw new IllegalStateException("Invalid nonce claim", ex); + } + + if (isNull(nonce)) { + throw new IllegalStateException("Missing nonce claim"); + } + if (isNull(expectedNonceHash)) { + throw new IllegalStateException("Missing expected nonce claim"); + } + if (!expectedNonceHash.equals(nonce)) { + throw new IllegalStateException("Invalid nonce claim : " + nonce); + } + } + +// 5.5.1. Individual Claims Requests +// If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. The meaning and processing of acr Claim Values is out of scope for this specification. ?? +// If the auth_time Claim was requested, either through a specific request for this Claim or by using the max_age parameter, the Client SHOULD check the auth_time Claim value and request re-authentication if it determines too much time has elapsed since the last End-User authentication. + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/JWTValidator.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/JWTValidator.java new file mode 100644 index 0000000..ab5c0ec --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/JWTValidator.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ + +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.*; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jose.util.ResourceRetriever; +import com.nimbusds.jwt.*; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.JWTClaimsSetVerifier; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.text.ParseException; +import java.util.concurrent.ConcurrentHashMap; + +import static com.nimbusds.jose.jwk.source.RemoteJWKSet.DEFAULT_HTTP_SIZE_LIMIT; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.isNull; + +@ApplicationScoped +public class JWTValidator { + @Inject + private OpenIdConfiguration configuration; + + private ConcurrentHashMap jwsCache = new ConcurrentHashMap<>(); + private ConcurrentHashMap jweCache = new ConcurrentHashMap<>(); + + + public JWTClaimsSet validateBearerToken(JWT token, JWTClaimsSetVerifier jwtVerifier) { + JWTClaimsSet claimsSet; + try { + if (token instanceof PlainJWT) { + PlainJWT plainToken = (PlainJWT) token; + claimsSet = plainToken.getJWTClaimsSet(); + jwtVerifier.verify(claimsSet, null); + } else if (token instanceof SignedJWT) { + SignedJWT signedToken = (SignedJWT) token; + JWSHeader header = signedToken.getHeader(); + String alg = header.getAlgorithm().getName(); + if (isNull(alg)) { + // set the default value + alg = OpenIdConstant.DEFAULT_JWT_SIGNED_ALGORITHM; + } + + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor(); + jwtProcessor.setJWSKeySelector(getJWSKeySelector(alg)); + jwtProcessor.setJWTClaimsSetVerifier(jwtVerifier); + claimsSet = jwtProcessor.process(signedToken, null); + } else if (token instanceof EncryptedJWT) { + /* + * If ID Token is encrypted, decrypt it using the keys and + * algorithms + */ + EncryptedJWT encryptedToken = (EncryptedJWT) token; + JWEHeader header = encryptedToken.getHeader(); + String alg = header.getAlgorithm().getName(); + + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor(); + jwtProcessor.setJWSKeySelector(getJWSKeySelector(alg)); + // Only JWS supported, not JWE + jwtProcessor.setJWTClaimsSetVerifier(jwtVerifier); + claimsSet = jwtProcessor.process(encryptedToken, null); + } else { + throw new IllegalStateException("Unexpected JWT type : " + token.getClass()); + } + } catch (ParseException | BadJOSEException | JOSEException ex) { + throw new IllegalStateException(ex); + } + return claimsSet; + } + + /** + * JWSKeySelector finds the JSON Web Key Set (JWKS) from jwks_uri endpoint + * and filter for potential signing keys in the JWKS with a matching kid + * property. + * + * @param alg the algorithm for the key + * @return the JSON Web Signing (JWS) key selector + */ + private JWSKeySelector getJWSKeySelector(String alg) { + return jwsCache.computeIfAbsent(createCacheKey(alg), k -> createJWSKeySelector(alg)); + } + + private CacheKey createCacheKey(String alg) { + return new CacheKey(alg, + configuration.getJwksConnectTimeout(), + configuration.getJwksReadTimeout(), + configuration.getProviderMetadata().getJwksURL(), + configuration.getClientSecret()); + } + + private JWSKeySelector createJWSKeySelector(String alg) { + JWKSource jwkSource; + JWSAlgorithm jWSAlgorithm = new JWSAlgorithm(alg); + if (Algorithm.NONE.equals(jWSAlgorithm)) { + throw new IllegalStateException("Unsupported JWS algorithm : " + jWSAlgorithm); + } else if (JWSAlgorithm.Family.RSA.contains(jWSAlgorithm) + || JWSAlgorithm.Family.EC.contains(jWSAlgorithm)) { + ResourceRetriever jwkSetRetriever = new DefaultResourceRetriever( + configuration.getJwksConnectTimeout(), + configuration.getJwksReadTimeout(), + DEFAULT_HTTP_SIZE_LIMIT + ); + jwkSource = new RemoteJWKSet<>(configuration.getProviderMetadata().getJwksURL(), jwkSetRetriever); + } else if (JWSAlgorithm.Family.HMAC_SHA.contains(jWSAlgorithm)) { + byte[] clientSecret = new String(configuration.getClientSecret()).getBytes(UTF_8); + if (isNull(clientSecret)) { // FIXME + throw new IllegalStateException("Missing client secret"); + } + jwkSource = new ImmutableSecret<>(clientSecret); + } else { + throw new IllegalStateException("Unsupported JWS algorithm : " + jWSAlgorithm); + } + return new JWSVerificationKeySelector<>(jWSAlgorithm, jwkSource); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/NonceController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/NonceController.java new file mode 100644 index 0000000..4babb54 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/NonceController.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jose.util.Base64URL; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdNonce; +import org.glassfish.soteria.openid.http.HttpStorageController; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Objects.requireNonNull; + +/** + * Controller to manage nonce state and create the nonce hash. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class NonceController { + + private static final String NONCE_KEY = "oidc.nonce"; + + public void store( + OpenIdNonce nonce, + OpenIdConfiguration configuration, + HttpServletRequest request, + HttpServletResponse response) { + + HttpStorageController.getInstance(configuration, request, response) + .store(NONCE_KEY, nonce.getValue(), null); + + } + + public OpenIdNonce get( + OpenIdConfiguration configuration, + HttpServletRequest request, + HttpServletResponse response) { + + return HttpStorageController.getInstance(configuration, request, response) + .getAsString(NONCE_KEY) + .filter(k -> !Utils.isEmpty(k)) + .map(OpenIdNonce::new) + .orElse(null); + } + + public void remove( + OpenIdConfiguration configuration, + HttpServletRequest request, + HttpServletResponse response) { + + HttpStorageController.getInstance(configuration, request, response) + .remove(NONCE_KEY); + } + + public String getNonceHash(OpenIdNonce nonce) { + requireNonNull(nonce, "OpenId nonce value must not be null"); + + String nonceHash; + try { + MessageDigest md = MessageDigest.getInstance(OpenIdConstant.DEFAULT_HASH_ALGORITHM); + md.update(nonce.getValue().getBytes(US_ASCII)); + byte[] hash = md.digest(); + nonceHash = Base64URL.encode(hash).toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("No MessageDigest instance found with the specified algorithm for nonce hash", ex); + } + return nonceHash; + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/ProviderMetadataController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/ProviderMetadataController.java new file mode 100644 index 0000000..4337986 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/ProviderMetadataController.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.soteria.Utils; + +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Objects.isNull; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Manages the OpenId Connect Provider metadata + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class ProviderMetadataController { + + private static final String WELL_KNOWN_PREFIX = "/.well-known/openid-configuration"; + + private final Map providerDocuments = new HashMap<>(); + + /** + * Request to the provider + * https://example.com/.well-known/openid-configuration to obtain its + * Configuration information / document which includes all necessary + * endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint, + * revocation_endpoint etc), scopes, Claims, and public key location + * information (jwks_uri) + * + * @param providerURI the OpenID Provider's uri + * @return the OpenID Provider's configuration information / document + * + */ + public JsonObject getDocument(String providerURI) { + if (isNull(providerDocuments.get(providerURI))) { + if (Utils.isEmpty(providerURI)) { + // empty providerURI so all data needs to be defined within OpenIdProviderMetadata structure + providerDocuments.put(providerURI, Json.createObjectBuilder().build()); + } else { + if (providerURI.endsWith("/")) { + providerURI = providerURI.substring(0, providerURI.length() - 1); + } + + // Append WELL_KNOWN_PREFIX to the URL + if (!providerURI.endsWith(WELL_KNOWN_PREFIX)) { + providerURI = providerURI + WELL_KNOWN_PREFIX; + } + + // Call + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(providerURI); + Response response = target.request() + .accept(APPLICATION_JSON) + .get(); + + if (response.getStatus() == Status.OK.getStatusCode()) { + // Get back the result of the REST request + String responseBody = response.readEntity(String.class); + try (JsonReader reader = Json.createReader(new StringReader(responseBody))) { + JsonObject responseObject = reader.readObject(); + providerDocuments.put(providerURI, responseObject); + } + } else { + throw new IllegalStateException(String.format( + "Unable to retrieve OpenID Provider's [%s] configuration document, HTTP respons code : [%s] ", + providerURI, + response.getStatus() + )); + } + } + } + return providerDocuments.get(providerURI); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/RefreshedIdTokenClaimsSetVerifier.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/RefreshedIdTokenClaimsSetVerifier.java new file mode 100644 index 0000000..2a6e38b --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/RefreshedIdTokenClaimsSetVerifier.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.BadJWTException; +import jakarta.security.enterprise.identitystore.openid.IdentityToken; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.util.List; + +import static java.util.Objects.isNull; + +/** + * Validates the ID token received from the Refresh token response + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class RefreshedIdTokenClaimsSetVerifier extends TokenClaimsSetVerifier { + + private final IdentityToken previousIdToken; + + public RefreshedIdTokenClaimsSetVerifier(IdentityToken previousIdToken, OpenIdConfiguration configuration) { + super(configuration); + this.previousIdToken = previousIdToken; + } + + /** + * Validate ID Token's claims received from the Refresh token response + * + * @param claims + * @throws com.nimbusds.jwt.proc.BadJWTException + */ + @Override + public void verify(JWTClaimsSet claims) throws BadJWTException { + + String previousIssuer = previousIdToken.getJwtClaims().getIssuer().orElse(null); + String newIssuer = claims.getIssuer(); + if (newIssuer == null || !newIssuer.equals(previousIssuer)) { + throw new IllegalStateException("iss Claim Value MUST be the same as in the ID Token issued when the original authentication occurred."); + } + + String previousSubject = previousIdToken.getJwtClaims().getSubject().orElse(null); + String newSubject = claims.getSubject(); + if (newSubject == null || !newSubject.equals(previousSubject)) { + throw new IllegalStateException("sub Claim Value MUST be the same as in the ID Token issued when the original authentication occurred."); + } + + List previousAudience = previousIdToken.getJwtClaims().getAudience(); + List newAudience = claims.getAudience(); + if (newAudience == null || !newAudience.equals(previousAudience)) { + throw new IllegalStateException("aud Claim Value MUST be the same as in the ID Token issued when the original authentication occurred."); + } + + if (isNull(claims.getIssueTime())) { + throw new IllegalStateException("iat Claim Value must not be null."); + } + + String previousAzp = (String) previousIdToken.getClaims().get(OpenIdConstant.AUTHORIZED_PARTY); + String newAzp = (String) claims.getClaim(OpenIdConstant.AUTHORIZED_PARTY); + if (previousAzp == null ? newAzp != null : !previousAzp.equals(newAzp)) { + throw new IllegalStateException("azp Claim Value MUST be the same as in the ID Token issued when the original authentication occurred."); + } + + // if the ID Token contains an auth_time Claim, its value MUST represent the time of the original authentication - not the time that the new ID token is issued, + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/StateController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/StateController.java new file mode 100644 index 0000000..29eae04 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/StateController.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.OpenIdState; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.http.HttpStorageController; + +import java.util.Optional; + +/** + * Controller to manage OpenId state parameter value and request being validated + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class StateController { + + private static final String STATE_KEY = "oidc.state"; + + @Inject + private OpenIdConfiguration configuration; + + public void store( + OpenIdState state, + OpenIdConfiguration configuration, + HttpServletRequest request, + HttpServletResponse response) { + + HttpStorageController storage = HttpStorageController.getInstance(configuration, request, response); + + storage.store(STATE_KEY, state.getValue(), null); + } + + public Optional get( + HttpServletRequest request, + HttpServletResponse response) { + + return HttpStorageController.getInstance(configuration, request, response) + .getAsString(STATE_KEY) + .filter(k -> !Utils.isEmpty(k)) + .map(OpenIdState::new); + } + + public void remove( + HttpServletRequest request, + HttpServletResponse response) { + + HttpStorageController.getInstance(configuration, request, response) + .remove(STATE_KEY); + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenClaimsSetVerifier.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenClaimsSetVerifier.java new file mode 100644 index 0000000..8fd4270 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenClaimsSetVerifier.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.BadJWTException; +import com.nimbusds.jwt.proc.JWTClaimsSetVerifier; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static java.util.Objects.isNull; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + * @see OpenID Connect core 1.0, section 3.1.3.7 + */ +public abstract class TokenClaimsSetVerifier implements JWTClaimsSetVerifier { + + protected final OpenIdConfiguration configuration; + + public TokenClaimsSetVerifier(OpenIdConfiguration configuration) { + this.configuration = configuration; + } + + protected static class StandardVerifications { + private final OpenIdConfiguration configuration; + private final JWTClaimsSet claims; + + public StandardVerifications(OpenIdConfiguration configuration, JWTClaimsSet claims) { + this.configuration = configuration; + this.claims = claims; + } + + /** + * The Issuer Identifier for the OpenID Provider (which is typically + * obtained during Discovery) must exactly match the value of the iss + * (issuer) Claim. + */ + public void requireSameIssuer() { + if (isNull(claims.getIssuer())) { + throw new IllegalStateException("Missing issuer (iss) claim"); + } + if (!claims.getIssuer().equals(configuration.getProviderMetadata().getIssuerURI())) { + throw new IllegalStateException("Invalid issuer : " + configuration.getProviderMetadata().getIssuerURI()); + } + } + + /** + * Subject Identifier is locally unique and never reassigned identifier + * within the Issuer for the End-User. + */ + public void requireSubject() { + if (isNull(claims.getSubject())) { + throw new IllegalStateException("Missing subject (sub) claim"); + } + + } + + /** + * Audience(s) claim (that this ID Token is intended for) must contains + * the client_id of the Client (Relying Party) as an audience value. + * + * Other use cases may allow different audience than client Id, but generally require one. + */ + public void requireAudience(String requiredAudience) { + final List audience = claims.getAudience(); + if (isNull(audience) || audience.isEmpty()) { + throw new IllegalStateException("Missing audience (aud) claim"); + } + if (requiredAudience != null && !audience.contains(requiredAudience)) { + throw new IllegalStateException("Invalid audience (aud) claim " + audience); + } + } + + + /** + * If the ID Token contains multiple audiences, the Client should verify + * that an azp (authorized party) claim is present. + * + * If an azp (authorized party) claim is present, the Client should + * verify that its client_id is the claim Value + */ + public void assureAuthorizedParty(String clientId) { + Object authorizedParty = claims.getClaim(OpenIdConstant.AUTHORIZED_PARTY); + List audience = claims.getAudience(); + if (audience.size() > 1 && isNull(authorizedParty)) { + throw new IllegalStateException("Missing authorized party (azp) claim"); + } + + if (audience.size() > 1 + && !authorizedParty.equals(clientId)) { + throw new IllegalStateException("Invalid authorized party (azp) claim " + authorizedParty); + } + } + + /** + * The current time must be before the time represented by the exp + * Claim. + * + * The current time must be after the time represented by the iat Claim. + * + * The current time must be after the time represented by nbf claim + */ + public void requireValidTimestamp() { + long clockSkewInMillis = TimeUnit.MINUTES.toMillis(1); + long currentTime = System.currentTimeMillis(); + Date exp = claims.getExpirationTime(); + if (isNull(exp)) { + throw new IllegalStateException("Missing expiration time (exp) claim"); + } + if ((exp.getTime() + clockSkewInMillis) < currentTime) { + throw new IllegalStateException("Token is expired " + exp); + } + + Date iat = claims.getIssueTime(); + if (isNull(iat)) { + throw new IllegalStateException("Missing issue time (iat) claim"); + } + if ((iat.getTime() - clockSkewInMillis) > currentTime) { + throw new IllegalStateException("Issue time must be after current time " + iat); + } + + Date nbf = claims.getNotBeforeTime(); + if (!isNull(nbf) && (nbf.getTime() - clockSkewInMillis) > currentTime) { + throw new IllegalStateException("Token is not valid before " + nbf); + } + } + } + + @Override + public void verify(JWTClaimsSet claims, SecurityContext c) throws BadJWTException { + StandardVerifications standardVerifications = new StandardVerifications(configuration, claims); + + standardVerifications.requireSameIssuer(); + standardVerifications.requireSubject(); + standardVerifications.requireAudience(configuration.getClientId()); + standardVerifications.assureAuthorizedParty(configuration.getClientId()); + standardVerifications.requireValidTimestamp(); + + verify(claims); + } + + public abstract void verify(JWTClaimsSet jwtcs) throws BadJWTException; + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenController.java new file mode 100644 index 0000000..5241deb --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/TokenController.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + +import com.nimbusds.jose.Algorithm; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.JWTClaimsSetVerifier; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext; +import jakarta.security.enterprise.identitystore.openid.IdentityToken; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.security.enterprise.identitystore.openid.RefreshToken; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.Response; +import org.glassfish.soteria.openid.domain.AccessTokenImpl; +import org.glassfish.soteria.openid.domain.IdentityTokenImpl; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; +import org.glassfish.soteria.openid.domain.OpenIdNonce; + +import java.util.Map; + +import static java.util.Collections.emptyMap; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Controller for Token endpoint + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationScoped +public class TokenController { + + @Inject + private NonceController nonceController; + + @Inject + private OpenIdConfiguration configuration; + + @Inject + private JWTValidator validator; + + /** + * (4) A Client makes a token request to the token endpoint and the OpenId + * Provider responds with an ID Token and an Access Token. + * + * @param request + * @return a JSON object representation of OpenID Connect token response + * from the Token endpoint. + */ + public Response getTokens(HttpServletRequest request) { + /* + * one-time authorization code that RP exchange for an Access / Id token + */ + String authorizationCode = request.getParameter(OpenIdConstant.CODE); + + /* + * The Client sends the parameters to the Token Endpoint using the Form + * Serialization with all parameters to : + * + * 1. Authenticate client using CLIENT_ID & CLIENT_SECRET
+ * 2. Verify that the Authorization Code is valid
+ * 3. Ensure that the redirect_uri parameter value is identical to the + * initial authorization request's redirect_uri parameter value. + */ + Form form = new Form() + .param(OpenIdConstant.CLIENT_ID, configuration.getClientId()) + .param(OpenIdConstant.CLIENT_SECRET, new String(configuration.getClientSecret())) + .param(OpenIdConstant.GRANT_TYPE, OpenIdConstant.AUTHORIZATION_CODE) + .param(OpenIdConstant.CODE, authorizationCode) + .param(OpenIdConstant.REDIRECT_URI, configuration.buildRedirectURI(request)); + + // ID Token and Access Token Request + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(configuration.getProviderMetadata().getTokenEndpoint()); + return target.request() + .accept(APPLICATION_JSON) + .post(Entity.form(form)); + } + + /** + * (5.1) Validate Id Token's claims and verify ID Token's signature. + * + * @param idToken + * @param httpContext + * @return JWT Claims + */ + public JWTClaimsSet validateIdToken(IdentityTokenImpl idToken, HttpMessageContext httpContext) { + JWTClaimsSet claimsSet; + HttpServletRequest request = httpContext.getRequest(); + HttpServletResponse response = httpContext.getResponse(); + + /* + * The nonce in the returned ID Token is compared to the hash of the + * session cookie to detect ID Token replay by third parties. + */ + String expectedNonceHash = null; + if (configuration.isUseNonce()) { + OpenIdNonce expectedNonce = nonceController.get(configuration, request, response); + expectedNonceHash = nonceController.getNonceHash(expectedNonce); + } + + try { + JWTClaimsSetVerifier jwtVerifier = new IdTokenClaimsSetVerifier(expectedNonceHash, configuration); + claimsSet = validator.validateBearerToken(idToken.getTokenJWT(), jwtVerifier); + } finally { + nonceController.remove(configuration, request, response); + } + + return claimsSet; + } + + /** + * Validate Id Token received from Successful Refresh Response. + * + * @param previousIdToken + * @param newIdToken + * @return JWT Claims + */ + public JWTClaimsSet validateRefreshedIdToken(IdentityToken previousIdToken, IdentityTokenImpl newIdToken) { + JWTClaimsSetVerifier jwtVerifier = new RefreshedIdTokenClaimsSetVerifier(previousIdToken, configuration); + JWTClaimsSet claimsSet = validator.validateBearerToken(newIdToken.getTokenJWT(), jwtVerifier); + return claimsSet; + } + + /** + * (5.2) Validate the Access Token & it's claims and verify the signature. + * + * @param accessToken + * @param idTokenAlgorithm + * @param idTokenClaims + * @return JWT Claims + */ + public Map validateAccessToken(AccessTokenImpl accessToken, Algorithm idTokenAlgorithm, Map idTokenClaims) { + Map claims = emptyMap(); + + AccessTokenClaimsSetVerifier jwtVerifier = new AccessTokenClaimsSetVerifier( + accessToken, + idTokenAlgorithm, + idTokenClaims, + configuration + ); + + // https://support.okta.com/help/s/article/Signature-Validation-Failed-on-Access-Token +// if (accessToken.getType() == AccessToken.Type.BEARER) { +// JWTClaimsSet claimsSet = validateBearerToken(accessToken.getTokenJWT(), jwtVerifier, configuration); +// claims = claimsSet.getClaims(); +// } else { + jwtVerifier.validateAccessToken(); +// } + + return claims; + } + + /** + * Makes a refresh request to the token endpoint and the OpenId Provider + * responds with a new (updated) Access Token and Refreshs Token. + * + * @param refreshToken Refresh Token received from previous token request. + * @return a JSON object representation of OpenID Connect token response + * from the Token endpoint. + */ + public Response refreshTokens(RefreshToken refreshToken) { + + Form form = new Form() + .param(OpenIdConstant.CLIENT_ID, configuration.getClientId()) + .param(OpenIdConstant.CLIENT_SECRET, new String(configuration.getClientSecret())) + .param(OpenIdConstant.GRANT_TYPE, OpenIdConstant.REFRESH_TOKEN) + .param(OpenIdConstant.REFRESH_TOKEN, refreshToken.getToken()); + + // Access Token and RefreshToken Request + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(configuration.getProviderMetadata().getTokenEndpoint()); + return target.request() + .accept(APPLICATION_JSON) + .post(Entity.form(form)); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/controller/UserInfoController.java b/impl/src/main/java/org/glassfish/soteria/openid/controller/UserInfoController.java new file mode 100644 index 0000000..9cc98b2 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/controller/UserInfoController.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.controller; + + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.security.enterprise.identitystore.openid.AccessToken; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.security.enterprise.identitystore.openid.OpenIdContext; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.io.StringReader; +import java.util.logging.Logger; + +import static java.util.Objects.nonNull; +import static java.util.logging.Level.WARNING; +import static jakarta.ws.rs.core.HttpHeaders.CONTENT_TYPE; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Controller for Token endpoint + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@RequestScoped +public class UserInfoController { + + @Inject + private OpenIdContext context; + + private static final String APPLICATION_JWT = "application/jwt"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_TYPE = "Bearer "; + + private static final Logger LOGGER = Logger.getLogger(UserInfoController.class.getName()); + + /** + * (6) The RP send a request with the Access Token to the UserInfo Endpoint + * and requests the claims about the End-User. + * + * @param configuration the OpenId Connect client configuration configuration + * @param accessToken + * @return the claims json object + */ + public JsonObject getUserInfo(OpenIdConfiguration configuration, AccessToken accessToken) { + LOGGER.finest("Sending the request to the userinfo endpoint"); + JsonObject userInfo; + + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(configuration.getProviderMetadata().getUserinfoEndpoint()); + Response response = target.request() + .accept(APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, BEARER_TYPE + accessToken) + // 5.5. Requesting Claims using the "claims" Request Parameter ?? + .get(); + + String responseBody = response.readEntity(String.class); + + String contentType = response.getHeaderString(CONTENT_TYPE); + if (response.getStatus() == Status.OK.getStatusCode()) { + if (nonNull(contentType) && contentType.contains(APPLICATION_JSON)) { + // Successful UserInfo Response + try (JsonReader reader = Json.createReader(new StringReader(responseBody))) { + userInfo = reader.readObject(); + } + } else if (nonNull(contentType) && contentType.contains(APPLICATION_JWT)) { + throw new UnsupportedOperationException("application/jwt content-type not supported for userinfo endpoint"); + //If the UserInfo Response is signed and/or encrypted, then the Claims are returned in a JWT and the content-type MUST be application/jwt. The response MAY be encrypted without also being signed. If both signing and encryption are requested, the response MUST be signed then encrypted, with the result being a Nested JWT, ?? + //If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value SHOULD be the OP's Issuer Identifier URL. The aud value SHOULD be or include the RP's Client ID value. + } else { + throw new IllegalStateException("Invalid response received from userinfo endpoint with content-type : " + contentType); + } + } else { + // UserInfo Error Response + JsonObject responseObject = Json.createReader(new StringReader(responseBody)).readObject(); + String error = responseObject.getString(OpenIdConstant.ERROR_PARAM, "Unknown Error"); + String errorDescription = responseObject.getString(OpenIdConstant.ERROR_DESCRIPTION_PARAM, "Unknown"); + LOGGER.log(WARNING, "Error occurred in fetching user info: {0} caused by {1}", new Object[]{error, errorDescription}); + throw new IllegalStateException("Error occurred in fetching user info"); + } + validateUserInfoClaims(userInfo); + return userInfo; + } + + private void validateUserInfoClaims(JsonObject userInfo) { + /* + * Check the token substitution attacks : The sub Claim in the UserInfo + * Response must be verified to exactly match the sub claim in the ID + * Token. + */ + if (!context.getSubject().equals(userInfo.getString(OpenIdConstant.SUBJECT_IDENTIFIER))) { + throw new IllegalStateException("UserInfo Response is invalid as sub claim must match with the sub Claim in the ID Token"); + } + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/AccessTokenImpl.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/AccessTokenImpl.java new file mode 100644 index 0000000..6c63013 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/AccessTokenImpl.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import com.nimbusds.jwt.*; +import jakarta.security.enterprise.identitystore.openid.AccessToken; +import jakarta.security.enterprise.identitystore.openid.JwtClaims; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.security.enterprise.identitystore.openid.Scope; + +import java.text.ParseException; +import java.util.Date; +import java.util.Map; + +import static java.util.Collections.emptyMap; +import static java.util.Objects.nonNull; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class AccessTokenImpl implements AccessToken { + + private final String token; + private final long tokenMinValidity; + + private final AccessToken.Type type; + + private final JwtClaims jwtClaims; + + private JWT tokenJWT; + + private Map claims; + + private final Long expiresIn; + + private final Scope scope; + + private final long createdAt; + + public AccessTokenImpl(String tokenType, String token, Long expiresIn, String scopeValue, long tokenMinValidity) { + this.token = token; + this.tokenMinValidity = tokenMinValidity; + JWTClaimsSet jwtClaimsSet = null; + try { + this.tokenJWT = JWTParser.parse(token); + jwtClaimsSet = tokenJWT.getJWTClaimsSet(); + this.claims = jwtClaimsSet.getClaims(); + } catch (ParseException ex) { + // Access token doesn't need to be JWT at all + } + this.jwtClaims = NimbusJwtClaims.ifPresent(jwtClaimsSet); + + this.type = Type.valueOf(tokenType.toUpperCase()); + this.expiresIn = expiresIn; + this.createdAt = System.currentTimeMillis(); + this.scope = Scope.parse(scopeValue); + } + + @Override + public boolean isExpired() { + boolean expired; + Date exp; + if (nonNull(expiresIn)) { + expired = System.currentTimeMillis() + tokenMinValidity > createdAt + (expiresIn * 1000); + } else if (nonNull(exp = (Date) this.getClaim(OpenIdConstant.EXPIRATION_IDENTIFIER))) { + expired = System.currentTimeMillis() + tokenMinValidity > exp.getTime(); + } else { + throw new IllegalStateException("Missing expiration time (exp) claim in access token"); + } + return expired; + } + + @Override + public Type getType() { + return type; + } + + @Override + public String getToken() { + return token; + } + + @Override + public Map getClaims() { + if (claims == null) { + return emptyMap(); + } + return claims; + } + + public void setClaims(Map claims) { + this.claims = claims; + } + + @Override + public Object getClaim(String key) { + return getClaims().get(key); + } + + @Override + public Long getExpirationTime() { + return expiresIn; + } + + @Override + public Scope getScope() { + return scope; + } + + @Override + public boolean isJWT() { + return tokenJWT != null; + } + + @Override + public JwtClaims getJwtClaims() { + return jwtClaims; + } + + @Override + public String toString() { + return token; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/ClaimsConfiguration.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/ClaimsConfiguration.java new file mode 100644 index 0000000..7fb901e --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/ClaimsConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class ClaimsConfiguration { + + private String callerNameClaim; + + private String callerGroupsClaim; + + public String getCallerNameClaim() { + return callerNameClaim; + } + + public ClaimsConfiguration setCallerNameClaim(String callerNameClaim) { + this.callerNameClaim = callerNameClaim; + return this; + } + + public String getCallerGroupsClaim() { + return callerGroupsClaim; + } + + public ClaimsConfiguration setCallerGroupsClaim(String callerGroupsClaim) { + this.callerGroupsClaim = callerGroupsClaim; + return this; + } + + @Override + public String toString() { + return "ClaimsConfiguration{" + "callerNameClaim=" + callerNameClaim + ", callerGroupsClaim=" + callerGroupsClaim + '}'; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/IdentityTokenImpl.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/IdentityTokenImpl.java new file mode 100644 index 0000000..c8800c2 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/IdentityTokenImpl.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import jakarta.security.enterprise.identitystore.openid.IdentityToken; +import jakarta.security.enterprise.identitystore.openid.JwtClaims; + +import java.text.ParseException; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class IdentityTokenImpl implements IdentityToken { + + private final String token; + private final long tokenMinValidity; + + private final JWT tokenJWT; + + private final JWTClaimsSet claims; + + public IdentityTokenImpl(String token, long tokenMinValidity) { + this.token = token; + this.tokenMinValidity = tokenMinValidity; + try { + this.tokenJWT = JWTParser.parse(token); + this.claims = tokenJWT.getJWTClaimsSet(); + } catch (ParseException ex) { + throw new IllegalStateException("Error in parsing the Token", ex); + } + } + + private IdentityTokenImpl(JWT token, JWTClaimsSet verifiedClaims, long tokenMinValidity) { + this.token = token.getParsedString(); + this.tokenJWT = token; + this.claims = verifiedClaims; + this.tokenMinValidity = tokenMinValidity; + } + + public JWT getTokenJWT() { + return tokenJWT; + } + + @Override + public String getToken() { + return token; + } + + @Override + public JwtClaims getJwtClaims() { + return NimbusJwtClaims.ifPresent(this.claims); + } + + @Override + public boolean isExpired() { + boolean expired; + Optional expirationTime = this.getJwtClaims().getExpirationTime(); + if (expirationTime.isPresent()) { + expired = System.currentTimeMillis() + tokenMinValidity > expirationTime.get().toEpochMilli(); + } else { + throw new IllegalStateException("Missing expiration time (exp) claim in identity token"); + } + return expired; + } + + @Override + public Map getClaims() { + return claims.getClaims(); + } + + @Override + public String toString() { + return token; + } + + public IdentityToken withClaims(JWTClaimsSet verifiedClaims) { + return new IdentityTokenImpl(tokenJWT, verifiedClaims, tokenMinValidity); + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/JsonClaims.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/JsonClaims.java new file mode 100644 index 0000000..593e6ed --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/JsonClaims.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + + +import jakarta.json.JsonNumber; +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import jakarta.security.enterprise.identitystore.openid.Claims; +import jakarta.security.enterprise.identitystore.openid.OpenIdClaims; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +class JsonClaims implements OpenIdClaims { + private final JsonObject claims; + + JsonClaims(JsonObject claims) { + this.claims = claims; + } + + @Override + public Optional getStringClaim(String name) { + return Optional.ofNullable(claims.getString(name, null)); + } + + @Override + public Optional getNumericDateClaim(String name) { + return Optional.ofNullable(getNumber(name)) + .map(n -> Instant.ofEpochSecond(n.longValue())); + } + + @Override + public List getArrayStringClaim(String name) { + JsonValue value = claims.get(name); + if (value == null) { + return Collections.emptyList(); + } + if (value.getValueType() == JsonValue.ValueType.ARRAY) { + return value.asJsonArray().stream().map(this::getStringValue).collect(Collectors.toList()); + } + return Collections.singletonList(getStringValue(value)); + } + + private String getStringValue(JsonValue value) { + switch (value.getValueType()) { + case STRING: + return ((JsonString) value).getString(); + case TRUE: + return "true"; + case FALSE: + return "false"; + case NUMBER: + return ((JsonNumber) value).numberValue().toString(); + default: + throw new IllegalArgumentException("Cannot handle nested JSON value in a claim:" + value); + } + } + + private JsonNumber getNumber(String name) { + try { + return claims.getJsonNumber(name); + } catch (ClassCastException cce) { + throw new IllegalArgumentException("Cannot interpret " + name + " as number", cce); + } + } + + @Override + public OptionalInt getIntClaim(String name) { + JsonNumber value = getNumber(name); + return value == null ? OptionalInt.empty() : OptionalInt.of(value.intValue()); + } + + @Override + public OptionalLong getLongClaim(String name) { + JsonNumber value = getNumber(name); + return value == null ? OptionalLong.empty() : OptionalLong.of(value.longValue()); + } + + @Override + public OptionalDouble getDoubleClaim(String name) { + JsonNumber value = getNumber(name); + return value == null ? OptionalDouble.empty() : OptionalDouble.of(value.doubleValue()); + } + + @Override + public Optional getNested(String claimName) { + return Optional.ofNullable(claims.getJsonObject(claimName)).map(JsonClaims::new); + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{" + + "subject=" + getSubject() + + ",name=" + getName() + + ", familyName=" + getFamilyName() + + ", givenName=" + getGivenName() + + ", middleName=" + getMiddleName() + + ", nickname=" + getNickname() + + ", preferredUsername=" + getPreferredUsername() + + ", profile=" + getProfile() + + ", picture=" + getPicture() + + ", website=" + getWebsite() + + ", gender=" + getGender() + + ", birthdate=" + getBirthdate() + + ", zoneinfo=" + getZoneinfo() + + ", locale=" + getLocale() + + ", updatedAt=" + getUpdatedAt() + + ", email=" + getEmail() + + ", emailVerified=" + getEmailVerified() + + ", address=" + getAddress() + + ", phoneNumber=" + getPhoneNumber() + + ", phoneNumberVerified=" + getPhoneNumberVerified() + + '}'; + + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/LogoutConfiguration.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/LogoutConfiguration.java new file mode 100644 index 0000000..eab1e40 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/LogoutConfiguration.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import jakarta.servlet.http.HttpServletRequest; + +import static org.glassfish.soteria.openid.domain.OpenIdConfiguration.BASE_URL_EXPRESSION; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class LogoutConfiguration { + + private boolean notifyProvider; + + private String redirectURI; + + private boolean accessTokenExpiry; + + private boolean identityTokenExpiry; + + public boolean isNotifyProvider() { + return notifyProvider; + } + + public LogoutConfiguration setNotifyProvider(boolean notifyProvider) { + this.notifyProvider = notifyProvider; + return this; + } + + public String getRedirectURI() { + return redirectURI; + } + + public LogoutConfiguration setRedirectURI(String redirectURI) { + this.redirectURI = redirectURI; + return this; + } + + public String buildRedirectURI(HttpServletRequest request) { + if (redirectURI.contains(BASE_URL_EXPRESSION)) { + String baseURL = request.getRequestURL().substring(0, request.getRequestURL().length() - request.getRequestURI().length()) + + request.getContextPath(); + return redirectURI.replace(BASE_URL_EXPRESSION, baseURL); + } + return redirectURI; + } + + public boolean isAccessTokenExpiry() { + return accessTokenExpiry; + } + + public LogoutConfiguration setAccessTokenExpiry(boolean accessTokenExpiry) { + this.accessTokenExpiry = accessTokenExpiry; + return this; + } + + public boolean isIdentityTokenExpiry() { + return identityTokenExpiry; + } + + public LogoutConfiguration setIdentityTokenExpiry(boolean identityTokenExpiry) { + this.identityTokenExpiry = identityTokenExpiry; + return this; + } + + @Override + public String toString() { + return "LogoutConfiguration{" + "notifyProvider=" + notifyProvider + ", redirectURI=" + redirectURI + ", accessTokenExpiry=" + accessTokenExpiry + ", identityTokenExpiry=" + identityTokenExpiry + '}'; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/NimbusJwtClaims.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/NimbusJwtClaims.java new file mode 100644 index 0000000..6ca332f --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/NimbusJwtClaims.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import com.nimbusds.jwt.JWTClaimsSet; +import jakarta.security.enterprise.identitystore.openid.Claims; +import jakarta.security.enterprise.identitystore.openid.JwtClaims; + +import java.text.ParseException; +import java.time.Instant; +import java.util.*; + +class NimbusJwtClaims implements JwtClaims { + private final JWTClaimsSet claimsSet; + + NimbusJwtClaims(JWTClaimsSet claimsSet) { + this.claimsSet = claimsSet; + } + + @Override + public Optional getStringClaim(String name) { + try { + return Optional.ofNullable(claimsSet.getStringClaim(name)); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as string", e); + } + } + + @Override + public Optional getNumericDateClaim(String name) { + try { + return Optional.ofNullable(claimsSet.getDateClaim(name)).map(Date::toInstant); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as numeric date", e); + } + } + + @Override + public List getArrayStringClaim(String name) { + Object audValue = claimsSet.getClaim(name); + if (audValue == null) { + return Collections.emptyList(); + } + if (audValue instanceof String) { + return Collections.singletonList((String)audValue); + } + try { + return claimsSet.getStringListClaim(name); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as a string array", e); + } + } + + @Override + public OptionalInt getIntClaim(String name) { + Integer value; + try { + value = claimsSet.getIntegerClaim(name); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as number"); + } + return value == null ? OptionalInt.empty() : OptionalInt.of(value); + } + + @Override + public OptionalLong getLongClaim(String name) { + Long value; + try { + value = claimsSet.getLongClaim(name); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as number"); + } + return value == null ? OptionalLong.empty() : OptionalLong.of(value); + } + + @Override + public OptionalDouble getDoubleClaim(String name) { + Double value; + try { + value = claimsSet.getDoubleClaim(name); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse "+name+" as number"); + } + return value == null ? OptionalDouble.empty() : OptionalDouble.of(value); + } + + @Override + public Optional getNested(String name) { + return Optional.empty(); + } + + @Override + public String toString() { + return claimsSet.toString(); + } + + static JwtClaims ifPresent(JWTClaimsSet claimsSet) { + return claimsSet == null ? JwtClaims.NONE : new NimbusJwtClaims(claimsSet); + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdConfiguration.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdConfiguration.java new file mode 100644 index 0000000..23df7ae --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdConfiguration.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Arrays; +import java.util.Map; + +/** + * OpenId Connect client configuration. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class OpenIdConfiguration { + + private String clientId; + private char[] clientSecret; + private String redirectURI; + private String scopes; + private String responseType; + private String responseMode; + private Map extraParameters; + private String prompt; + private String display; + private boolean useNonce; + private boolean useSession; + private int jwksConnectTimeout; + private int jwksReadTimeout; + private OpenIdProviderData providerMetadata; + private ClaimsConfiguration claimsConfiguration; + private LogoutConfiguration logoutConfiguration; + private boolean tokenAutoRefresh; + private int tokenMinValidity; + + static final String BASE_URL_EXPRESSION = "${baseURL}"; + + public String getClientId() { + return clientId; + } + + public OpenIdConfiguration setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public char[] getClientSecret() { + return clientSecret; + } + + public OpenIdConfiguration setClientSecret(char[] clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public String buildRedirectURI(HttpServletRequest request) { + if (redirectURI.contains(BASE_URL_EXPRESSION)) { + String baseURL = request.getRequestURL().substring(0, request.getRequestURL().length() - request.getRequestURI().length()) + + request.getContextPath(); + return redirectURI.replace(BASE_URL_EXPRESSION, baseURL); + } + return redirectURI; + } + + public String getRedirectURI() { + return redirectURI; + } + + public OpenIdConfiguration setRedirectURI(String redirectURI) { + this.redirectURI = redirectURI; + return this; + } + + public String getScopes() { + return scopes; + } + + public OpenIdConfiguration setScopes(String scopes) { + this.scopes = scopes; + return this; + } + + public String getResponseType() { + return responseType; + } + + public OpenIdConfiguration setResponseType(String responseType) { + this.responseType = responseType; + return this; + } + + public String getResponseMode() { + return responseMode; + } + + public OpenIdConfiguration setResponseMode(String responseMode) { + this.responseMode = responseMode; + return this; + } + + public Map getExtraParameters() { + return extraParameters; + } + + public OpenIdConfiguration setExtraParameters(Map extraParameters) { + this.extraParameters = extraParameters; + return this; + } + + public String getPrompt() { + return prompt; + } + + public OpenIdConfiguration setPrompt(String prompt) { + this.prompt = prompt; + return this; + } + + public String getDisplay() { + return display; + } + + public OpenIdConfiguration setDisplay(String display) { + this.display = display; + return this; + } + + public boolean isUseNonce() { + return useNonce; + } + + public OpenIdConfiguration setUseNonce(boolean useNonce) { + this.useNonce = useNonce; + return this; + } + + public boolean isUseSession() { + return useSession; + } + + public int getJwksConnectTimeout() { + return jwksConnectTimeout; + } + + public OpenIdConfiguration setJwksConnectTimeout(int jwksConnectTimeout) { + this.jwksConnectTimeout = jwksConnectTimeout; + return this; + } + + public int getJwksReadTimeout() { + return jwksReadTimeout; + } + + public OpenIdConfiguration setJwksReadTimeout(int jwksReadTimeout) { + this.jwksReadTimeout = jwksReadTimeout; + return this; + } + + public OpenIdConfiguration setUseSession(boolean useSession) { + this.useSession = useSession; + return this; + } + + public OpenIdProviderData getProviderMetadata() { + return providerMetadata; + } + + public OpenIdConfiguration setProviderMetadata(OpenIdProviderData providerMetadata) { + this.providerMetadata = providerMetadata; + return this; + } + + public ClaimsConfiguration getClaimsConfiguration() { + return claimsConfiguration; + } + + public OpenIdConfiguration setClaimsConfiguration(ClaimsConfiguration claimsConfiguration) { + this.claimsConfiguration = claimsConfiguration; + return this; + } + + public LogoutConfiguration getLogoutConfiguration() { + return logoutConfiguration; + } + + public OpenIdConfiguration setLogoutConfiguration(LogoutConfiguration logoutConfiguration) { + this.logoutConfiguration = logoutConfiguration; + return this; + } + + public boolean isTokenAutoRefresh() { + return tokenAutoRefresh; + } + + public OpenIdConfiguration setTokenAutoRefresh(boolean tokenAutoRefresh) { + this.tokenAutoRefresh = tokenAutoRefresh; + return this; + } + + public int getTokenMinValidity() { + return tokenMinValidity; + } + + public OpenIdConfiguration setTokenMinValidity(int tokenMinValidity) { + this.tokenMinValidity = tokenMinValidity; + return this; + } + + @Override + public String toString() { + return OpenIdConfiguration.class.getSimpleName() + + "{" + + "clientID=" + clientId + + ", clientSecret=" + Arrays.toString(clientSecret) + + ", redirectURI=" + redirectURI + + ", scopes=" + scopes + + ", responseType=" + responseType + + ", responseMode=" + responseMode + + ", extraParameters=" + extraParameters + + ", prompt=" + prompt + + ", display=" + display + + ", useNonce=" + useNonce + + ", useSession=" + useSession + + ", providerMetadata=" + providerMetadata + + ", claimsConfiguration=" + claimsConfiguration + + ", tokenAutoRefresh=" + tokenAutoRefresh + + ", tokenMinValidity=" + tokenMinValidity + + '}'; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdContextImpl.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdContextImpl.java new file mode 100644 index 0000000..3f00f69 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdContextImpl.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import jakarta.enterprise.context.SessionScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.security.enterprise.identitystore.openid.*; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.UriBuilder; +import org.glassfish.soteria.Utils; +import org.glassfish.soteria.openid.controller.AuthenticationController; +import org.glassfish.soteria.openid.controller.UserInfoController; +import org.glassfish.soteria.openid.http.HttpStorageController; + +import java.io.IOException; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; + +import static java.util.logging.Level.WARNING; + +/** + * An injectable interface that provides access to access token, identity token, + * claims and OpenId Connect provider related information. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@SessionScoped +public class OpenIdContextImpl implements OpenIdContext { + @Inject + private UserInfoController userInfoController; + + private String callerName; + private Set callerGroups; + private String tokenType; + private AccessToken accessToken; + private IdentityToken identityToken; + private RefreshToken refreshToken; + private Long expiresIn; + private JsonObject claims; + + @Inject + private OpenIdConfiguration configuration; + + @Inject + private AuthenticationController authenticationController; + + private static final Logger LOGGER = Logger.getLogger(OpenIdContextImpl.class.getName()); + + @Override + public String getCallerName() { + return callerName; + } + + public void setCallerName(String callerName) { + this.callerName = callerName; + } + + @Override + public Set getCallerGroups() { + return callerGroups; + } + + public void setCallerGroups(Set callerGroups) { + this.callerGroups = callerGroups; + } + + @Override + public String getSubject() { + return getIdentityToken().getJwtClaims().getSubject().orElse(null); + } + + @Override + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + @Override + public AccessToken getAccessToken() { + return accessToken; + } + + public void setAccessToken(AccessToken token) { + this.accessToken = token; + } + + @Override + public IdentityToken getIdentityToken() { + return identityToken; + } + + public void setIdentityToken(IdentityToken identityToken) { + this.identityToken = identityToken; + } + + @Override + public Optional getRefreshToken() { + return Optional.ofNullable(refreshToken); + } + + public void setRefreshToken(RefreshToken refreshToken) { + this.refreshToken = refreshToken; + } + + @Override + public Optional getExpiresIn() { + return Optional.ofNullable(expiresIn); + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + @Override + public JsonObject getClaimsJson() { + if (claims == null) { + if (configuration != null && accessToken != null) { + claims = userInfoController.getUserInfo(configuration, accessToken); + } else { + claims = Json.createObjectBuilder().build(); + } + } + return claims; + } + + @Override + public OpenIdClaims getClaims() { + return new JsonClaims(getClaimsJson()); + } + + @Override + public JsonObject getProviderMetadata() { + return configuration.getProviderMetadata().getDocument(); + } + + public void logout(HttpServletRequest request, HttpServletResponse response) { + LogoutConfiguration logout = configuration.getLogoutConfiguration(); + try { + request.logout(); + } catch (ServletException ex) { + LOGGER.log(WARNING, "Failed to logout the user.", ex); + } + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + + if (logout == null) { + LOGGER.log(WARNING, "Logout invoked on session without OpenID session"); + redirect(response, request.getContextPath()); + return; + } + /* + * See section 5. RP-Initiated Logout + * https://openid.net/specs/openid-connect-session-1_0.html#RPLogout + */ + if (logout.isNotifyProvider() + && !Utils.isEmpty(configuration.getProviderMetadata().getEndSessionEndpoint())) { + UriBuilder logoutURI = UriBuilder.fromUri(configuration.getProviderMetadata().getEndSessionEndpoint()) + .queryParam(OpenIdConstant.ID_TOKEN_HINT, getIdentityToken().getToken()); + if (!Utils.isEmpty(logout.getRedirectURI())) { + // User Agent redirected to POST_LOGOUT_REDIRECT_URI after a logout operation performed in OP. + logoutURI.queryParam(OpenIdConstant.POST_LOGOUT_REDIRECT_URI, logout.buildRedirectURI(request)); + } + redirect(response, logoutURI.toString()); + } else if (!Utils.isEmpty(logout.getRedirectURI())) { + redirect(response, logout.buildRedirectURI(request)); + } else { + // Redirect user to OpenID connect provider for re-authentication + authenticationController.authenticateUser(request, response); + } + } + + private static void redirect(HttpServletResponse response, String uri) { + try { + response.sendRedirect(uri); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Optional getStoredValue(HttpServletRequest request, + HttpServletResponse response, + String key) { + return HttpStorageController.getInstance(configuration, request, response).get(key); + } +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdNonce.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdNonce.java new file mode 100644 index 0000000..95847e6 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdNonce.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import org.glassfish.soteria.Utils; + +import java.io.Serializable; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.isNull; + +/** + * Creates a random nonce as a character sequence of the specified byte length + * and base64 url encoded. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class OpenIdNonce implements Serializable { + + /** + * The default byte length of randomly generated nonce. + */ + private static final int DEFAULT_BYTE_LENGTH = 32; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final String value; + + public OpenIdNonce() { + this(DEFAULT_BYTE_LENGTH); + } + + /** + * Creates a new nonce with the given nonce value. + * + * @param value The nonce value. Must not be {@code null} or empty. + */ + public OpenIdNonce(String value) { + if (Utils.isEmpty(value)) { + throw new IllegalArgumentException("The nonce value can't be null or empty"); + } + this.value = value; + } + + /** + * @param byteLength The byte length of the randomly generated value. + */ + public OpenIdNonce(int byteLength) { + if (byteLength < 1) { + throw new IllegalArgumentException("The byte length value must be greater than one"); + } + byte[] array = new byte[byteLength]; + SECURE_RANDOM.nextBytes(array); + value = new String(Base64.getUrlEncoder().withoutPadding().encode(array), UTF_8); + } + + /** + * + * @return The generated random nonce. + */ + public String getValue() { + return value; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 29 * hash + Objects.hashCode(this.value); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (isNull(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final OpenIdNonce other = (OpenIdNonce) obj; + return Objects.equals(this.value, other.value); + } + + @Override + public String toString() { + return getValue(); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdProviderData.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdProviderData.java new file mode 100644 index 0000000..7b2d98f --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/OpenIdProviderData.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import java.net.URL; +import java.util.Set; + +import static jakarta.security.enterprise.identitystore.openid.OpenIdConstant.*; +import static java.util.Collections.emptySet; +import static java.util.Objects.isNull; +import static java.util.stream.Collectors.toSet; +import static jakarta.json.JsonValue.ValueType.STRING; + +/** + * OpenId Connect Provider information. + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class OpenIdProviderData { + + private final JsonObject document; + private String issuerURI; + private String authorizationEndpoint; + private String tokenEndpoint; + private String userinfoEndpoint; + private String endSessionEndpoint; + private URL jwksURL; + private final Set scopesSupported; + private Set responseTypeSupported; + private Set idTokenSigningAlgorithmsSupported; + private Set subjectTypesSupported; + + public OpenIdProviderData(JsonObject document) { + this.document = document; + this.scopesSupported = getValues(SCOPES_SUPPORTED); + } + + public String getIssuerURI() { + return issuerURI; + } + + public OpenIdProviderData setIssuer(String issuerURI) { + this.issuerURI = issuerURI; + return this; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public OpenIdProviderData setAuthorizationEndpoint(String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + return this; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public OpenIdProviderData setTokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public String getUserinfoEndpoint() { + return userinfoEndpoint; + } + + public OpenIdProviderData setUserinfoEndpoint(String userinfoEndpoint) { + this.userinfoEndpoint = userinfoEndpoint; + return this; + } + + public String getEndSessionEndpoint() { + return endSessionEndpoint; + } + + public OpenIdProviderData setEndSessionEndpoint(String endSessionEndpoint) { + this.endSessionEndpoint = endSessionEndpoint; + return this; + } + + public URL getJwksURL() { + return jwksURL; + } + + public OpenIdProviderData setJwksURL(URL jwksURL) { + this.jwksURL = jwksURL; + return this; + } + + public JsonObject getDocument() { + return document; + } + + public Set getScopesSupported() { + return scopesSupported; + } + + public Set getResponseTypeSupported() { + return responseTypeSupported; + } + + public OpenIdProviderData setResponseTypeSupported(Set responseTypeSupported) { + this.responseTypeSupported = responseTypeSupported; + return this; + } + + public Set getSubjectTypesSupported() { + return subjectTypesSupported; + } + + public OpenIdProviderData setSubjectTypesSupported(Set subjectTypesSupported) { + this.subjectTypesSupported = subjectTypesSupported; + return this; + } + + public Set getIdTokenSigningAlgorithmsSupported() { + return idTokenSigningAlgorithmsSupported; + } + + public OpenIdProviderData setIdTokenSigningAlgorithmsSupported(Set idTokenSigningAlgorithmsSupported) { + this.idTokenSigningAlgorithmsSupported = idTokenSigningAlgorithmsSupported; + return this; + } + + private Set getValues(String key) { + JsonArray jsonArray = document.getJsonArray(key); + if (isNull(jsonArray)) { + return emptySet(); + } else { + return jsonArray + .stream() + .filter(element -> element.getValueType() == STRING) + .map(element -> (JsonString) element) + .map(JsonString::getString) + .collect(toSet()); + } + } + + @Override + public String toString() { + return OpenIdProviderData.class.getSimpleName() + + "{" + + "issuerURI=" + issuerURI + + ", authorizationEndpoint=" + authorizationEndpoint + + ", tokenEndpoint=" + tokenEndpoint + + ", userinfoEndpoint=" + userinfoEndpoint + + ", jwksURI=" + jwksURL + + '}'; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/domain/RefreshTokenImpl.java b/impl/src/main/java/org/glassfish/soteria/openid/domain/RefreshTokenImpl.java new file mode 100644 index 0000000..0307a9e --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/domain/RefreshTokenImpl.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.domain; + + +import jakarta.security.enterprise.identitystore.openid.RefreshToken; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class RefreshTokenImpl implements RefreshToken { + + private final String token; + + public RefreshTokenImpl(String token) { + this.token = token; + } + + @Override + public String getToken() { + return token; + } + + @Override + public String toString() { + return token; + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/http/CookieController.java b/impl/src/main/java/org/glassfish/soteria/openid/http/CookieController.java new file mode 100644 index 0000000..719e7f6 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/http/CookieController.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.http; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.glassfish.soteria.Utils; + +import java.util.Optional; + +import static java.util.Objects.nonNull; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class CookieController implements HttpStorageController { + + private final HttpServletRequest request; + private final HttpServletResponse response; + + public CookieController(HttpServletRequest request, HttpServletResponse response) { + this.request = request; + this.response = response; + } + + @Override + public void store(String name, String value, Integer maxAge) { + Cookie cookie = new Cookie(name, value); + if (maxAge != null) { + cookie.setMaxAge(maxAge); + } + cookie.setHttpOnly(true); + cookie.setSecure(true); + String contextPath = request.getContextPath(); + cookie.setPath(Utils.isEmpty(contextPath) ? "/" : contextPath); + + response.addCookie(cookie); + } + + @Override + public Optional get(String name) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (name.equals(cookie.getName()) + && nonNull(cookie.getValue()) + && !cookie.getValue().trim().isEmpty()) { + return Optional.of(cookie); + } + } + } + return Optional.empty(); + } + + @Override + public Optional getAsString(String name) { + return get(name).map(Cookie::getValue); + } + + @Override + public void remove(String name) { + store(name, null, 0); + } + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/http/HttpStorageController.java b/impl/src/main/java/org/glassfish/soteria/openid/http/HttpStorageController.java new file mode 100644 index 0000000..5e20717 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/http/HttpStorageController.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.http; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.glassfish.soteria.openid.domain.OpenIdConfiguration; + +import java.util.Optional; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public interface HttpStorageController { + + /** + * Factory method to retrieve the configured {@link HttpStorageController}. + * @param configuration + * @param request + * @param response + * @return + */ + static HttpStorageController getInstance( + OpenIdConfiguration configuration, + HttpServletRequest request, + HttpServletResponse response) { + HttpStorageController controller; + + if (configuration.isUseSession()) { + controller = new SessionController(request); + } else { + controller = new CookieController(request, response); + } + + return controller; + } + + void store(String name, String value, Integer maxAge); + + Optional get(String name); + + Optional getAsString(String name); + + void remove(String name); + +} diff --git a/impl/src/main/java/org/glassfish/soteria/openid/http/SessionController.java b/impl/src/main/java/org/glassfish/soteria/openid/http/SessionController.java new file mode 100644 index 0000000..88d7710 --- /dev/null +++ b/impl/src/main/java/org/glassfish/soteria/openid/http/SessionController.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + * Initially authored in Security Connectors + */ +package org.glassfish.soteria.openid.http; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import java.util.Optional; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +public class SessionController implements HttpStorageController { + + private final HttpServletRequest request; + + public SessionController(HttpServletRequest request) { + this.request = request; + } + + @Override + public void store(String name, String value, Integer maxAge) { + HttpSession session = request.getSession(); + session.setAttribute(name, value); + } + + @Override + public Optional get(String name) { + HttpSession session = request.getSession(false); + if (session != null) { + return Optional.ofNullable(session.getAttribute(name)); + } else { + return Optional.empty(); + } + } + + @Override + public Optional getAsString(String name) { + return get(name).map(Object::toString); + } + + @Override + public void remove(String name) { + HttpSession session = request.getSession(false); + if (session != null) { + session.removeAttribute(name); + } + } + +} diff --git a/impl/src/main/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension b/impl/src/main/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension index 99f41f9..3c6a53c 100644 --- a/impl/src/main/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension +++ b/impl/src/main/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension @@ -1 +1,2 @@ org.glassfish.soteria.cdi.CdiExtension +org.glassfish.soteria.openid.OpenIdExtension diff --git a/pom.xml b/pom.xml index 8f07a64..6b73ea0 100644 --- a/pom.xml +++ b/pom.xml @@ -72,7 +72,7 @@ - 2.0.0 + 3.0.0-SNAPSHOT UTF-8 UTF-8 @@ -139,6 +139,18 @@ 3.0.0 + + jakarta.ws.rs + jakarta.ws.rs-api + 3.0.0 + + + + com.nimbusds + nimbus-jose-jwt + 9.10.1 + + junit junit From 83ba957e541ebc836c51f2185ba1565c9c0373a6 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Sat, 9 Oct 2021 14:56:27 +0200 Subject: [PATCH 2/7] Set correct JDK version and fix Glassfish and Payara test configuration --- test/pom.xml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/pom.xml b/test/pom.xml index fa7e2d5..19cefc0 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -72,10 +72,13 @@ + 11 + 11 + - 5.194 + 6.2021.1.Alpha1 LATEST @@ -347,7 +350,7 @@ copy - ${session.executionRootDirectory}/target/payara5/glassfish/modules + ${session.executionRootDirectory}/target/payara6/glassfish/modules true true ${session.executionRootDirectory}/target/dependency-maven-plugin-markers @@ -358,7 +361,7 @@ jakarta.security.enterprise ${project.version} true - ${session.executionRootDirectory}/target/payara5/glassfish/modules + ${session.executionRootDirectory}/target/payara6/glassfish/modules @@ -366,7 +369,7 @@ soteria.spi.bean.decorator.weld ${project.version} true - ${session.executionRootDirectory}/target/payara5/glassfish/modules + ${session.executionRootDirectory}/target/payara6/glassfish/modules @@ -387,8 +390,8 @@ - deleting ${antProperty}/target/payara5/glassfish/modules/microprofile-jwt-auth.jar - + deleting ${antProperty}/target/payara6/glassfish/modules/microprofile-jwt-auth.jar + @@ -471,11 +474,11 @@ - ${session.executionRootDirectory}/target/payara5 + ${session.executionRootDirectory}/target/payara6 - ${session.executionRootDirectory}/target/payara5 + ${session.executionRootDirectory}/target/payara6 @@ -631,7 +634,7 @@ - ${project.build.directory}/glassfish5 + ${project.build.directory}/glassfish6 From 6d6ae1ad676d213e2466f6be4d8027450cc0b409 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Sat, 9 Oct 2021 14:57:42 +0200 Subject: [PATCH 3/7] Small code fixes related to OpenIdConnect --- .../java/org/glassfish/soteria/openid/OpenIdExtension.java | 5 ++--- .../org/glassfish/soteria/openid/OpenIdIdentityStore.java | 4 +--- pom.xml | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java index f2c662d..dc304c3 100644 --- a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdExtension.java @@ -104,10 +104,9 @@ protected void validateExtraParametersFormat(OpenIdAuthenticationDefinition defi protected void registerDefinition(@Observes AfterBeanDiscovery afterBeanDiscovery, BeanManager beanManager) { - LOGGER.log(INFO, "AfterBean Discovery {0}", - definition.getClass().getName()); - if (definition != null) { + LOGGER.log(INFO, "AfterBean Discovery {0}", + definition.getClass().getName()); // if definition is active we broaden the type of OpenIdAuthenticationMechanism back to // HttpAuthenticationMechanism, so it would be picked up by Jakarta Security. diff --git a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java index c43b18b..6301c40 100644 --- a/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java +++ b/impl/src/main/java/org/glassfish/soteria/openid/OpenIdIdentityStore.java @@ -117,9 +117,7 @@ public CredentialValidationResult validate(Credential credential) { private String getCallerName() { String callerNameClaim = configuration.getClaimsConfiguration().getCallerNameClaim(); - if (OpenIdConstant.SUBJECT_IDENTIFIER.equals(callerNameClaim)) { - return context.getSubject(); - } + String callerName = context.getIdentityToken().getJwtClaims().getStringClaim(callerNameClaim).orElse(null); if (callerName == null) { callerName = context.getAccessToken().getJwtClaims().getStringClaim(callerNameClaim).orElse(null); diff --git a/pom.xml b/pom.xml index 6b73ea0..12eb1bd 100644 --- a/pom.xml +++ b/pom.xml @@ -77,8 +77,8 @@ UTF-8 UTF-8 - 1.8 - 1.8 + 11 + 11 From b923b578a4aa420c87d2f9377cf7029cad82a268 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Sat, 9 Oct 2021 19:58:02 +0200 Subject: [PATCH 4/7] Simple default Integration test (with integrated OpenIdConnect fake implementation) --- test/app-openid/pom.xml | 54 +++++ .../soteria/test/client/Callback.java | 44 ++++ .../soteria/test/client/GetUserName.java | 41 ++++ .../soteria/test/client/UnsecuredPage.java | 40 ++++ .../test/client/defaulttests/SecuredPage.java | 52 +++++ .../test/server/ApplicationConfig.java | 39 ++++ .../soteria/test/server/OidcProvider.java | 214 ++++++++++++++++++ .../main/resources/openid-configuration.json | 51 +++++ .../src/main/resources/payara-web.xml | 5 + .../src/main/webapp/WEB-INF/beans.xml | 0 .../soteria/test/OpenIdDefaultIT.java | 70 ++++++ .../soteria/test/OpenIdTestUtil.java | 101 +++++++++ test/pom.xml | 1 + 13 files changed, 712 insertions(+) create mode 100644 test/app-openid/pom.xml create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/Callback.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/GetUserName.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/UnsecuredPage.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/server/ApplicationConfig.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java create mode 100644 test/app-openid/src/main/resources/openid-configuration.json create mode 100644 test/app-openid/src/main/resources/payara-web.xml create mode 100644 test/app-openid/src/main/webapp/WEB-INF/beans.xml create mode 100644 test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java create mode 100644 test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java diff --git a/test/app-openid/pom.xml b/test/app-openid/pom.xml new file mode 100644 index 0000000..f01e137 --- /dev/null +++ b/test/app-openid/pom.xml @@ -0,0 +1,54 @@ + + + + + 4.0.0 + + + org.glassfish.soteria.test + parent + 3.0.0-SNAPSHOT + + + app-openid + war + + + false + + + + + org.glassfish.soteria.test + common + 3.0.0-SNAPSHOT + + + + org.jboss.shrinkwrap.resolver + shrinkwrap-resolver-depchain + 3.1.3 + test + pom + + + + + app-openid + + diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/Callback.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/Callback.java new file mode 100644 index 0000000..7811f26 --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/Callback.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test.client; + + +import java.io.IOException; +import jakarta.inject.Inject; +import jakarta.security.enterprise.identitystore.openid.OpenIdContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@WebServlet("/Callback") +public class Callback extends HttpServlet { + + @Inject + private OpenIdContext context; + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.getWriter().println(context.getAccessToken()); + } + +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/GetUserName.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/GetUserName.java new file mode 100644 index 0000000..064bcac --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/GetUserName.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + + */ +package org.glassfish.soteria.test.client; + +import java.io.IOException; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * @author Ondrej Mihalyi + * @author Rudy De Busscher + */ +@WebServlet("/Username") +public class GetUserName extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String user = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : ""; + response.getWriter().print(user); + } +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/UnsecuredPage.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/UnsecuredPage.java new file mode 100644 index 0000000..156db53 --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/UnsecuredPage.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + + */ +package org.glassfish.soteria.test.client; + +import java.io.IOException; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@WebServlet("/Unsecured") +public class UnsecuredPage extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.getWriter().print("This is an unsecured web page"); + } + +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java new file mode 100644 index 0000000..2fd16fa --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test.client.defaulttests; + +import static org.glassfish.soteria.test.server.OidcProvider .CLIENT_ID_VALUE; +import static org.glassfish.soteria.test.server.OidcProvider .CLIENT_SECRET_VALUE; +import java.io.IOException; +import jakarta.annotation.security.DeclareRoles; +import jakarta.security.enterprise.identitystore.OpenIdAuthenticationDefinition; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.HttpConstraint; +import jakarta.servlet.annotation.ServletSecurity; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@WebServlet("/Secured") +@OpenIdAuthenticationDefinition( + providerURI = "http://localhost:8080/openid-server/webresources/oidc-provider-demo", + clientId = CLIENT_ID_VALUE, + clientSecret = CLIENT_SECRET_VALUE, + redirectURI = "${baseURL}/Callback" +) +@DeclareRoles("all") +@ServletSecurity(@HttpConstraint(rolesAllowed = "all")) +public class SecuredPage extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.getWriter().println("This is a secured web page"); + } +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/server/ApplicationConfig.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/ApplicationConfig.java new file mode 100644 index 0000000..ec4ec23 --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/ApplicationConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test.server; + +import java.util.Set; + +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.ApplicationPath; + +import static java.util.Collections.singleton; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@ApplicationPath("webresources") +public class ApplicationConfig extends Application { + + @Override + public Set> getClasses() { + return singleton(OidcProvider.class); + } + +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java new file mode 100644 index 0000000..e9732d8 --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + + */ +package org.glassfish.soteria.test.server; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObjectBuilder; +import jakarta.security.enterprise.identitystore.openid.OpenIdConstant; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; + +import static jakarta.security.enterprise.identitystore.openid.OpenIdConstant.*; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static java.util.Arrays.asList; +import static java.util.logging.Level.SEVERE; +import static java.util.stream.Collectors.joining; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@Path("/oidc-provider-demo") +public class OidcProvider { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_TYPE = "Bearer"; + + private static final String AUTH_CODE_VALUE = "sample_auth_code"; + private static final String ACCESS_TOKEN_VALUE = "sample_access_token"; + public static final String CLIENT_ID_VALUE = "sample_client_id"; + public static final String CLIENT_SECRET_VALUE = "sample_client_secret"; + + boolean rolesInUserInfoEndpoint = false; + + List userGroups = List.of("all"); + + @PathParam("subject") + String subject; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String get() { + return getSubject(); + } + + private static String nonce; + + private static final Logger LOGGER = Logger.getLogger(OidcProvider.class.getName()); + + @Path("/.well-known/openid-configuration") + @GET + @Produces(APPLICATION_JSON) + public Response getConfiguration() { + String result = null; + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + try (InputStream inputStream = classLoader.getResourceAsStream("openid-configuration.json")) { + result = new BufferedReader(new InputStreamReader(inputStream)) + .lines() + .collect(joining("\n")); + } catch (IOException ex) { + LOGGER.log(SEVERE, null, ex); + } + return Response.ok(result) + .header("Access-Control-Allow-Origin", "*") + .build(); + } + + @Path("/auth") + @GET + public Response authEndpoint(@QueryParam(CLIENT_ID) String clientId, + @QueryParam(SCOPE) String scope, + @QueryParam(RESPONSE_TYPE) String responseType, + @QueryParam(NONCE) String nonce, + @QueryParam(STATE) String state, + @QueryParam(REDIRECT_URI) String redirectUri) throws URISyntaxException { + + String returnURL = redirectUri + "?&" + STATE + "=" + state + + "&" + CODE + "=" + AUTH_CODE_VALUE; + + OidcProvider.nonce = nonce; + JsonObjectBuilder jsonBuilder = Json.createObjectBuilder(); + if (!CODE.equals(responseType)) { + jsonBuilder.add(ERROR_PARAM, "invalid_response_type"); + return Response.serverError().entity(jsonBuilder.build()).build(); + } + if (!CLIENT_ID_VALUE.equals(clientId)) { + jsonBuilder.add(ERROR_PARAM, "invalid_client_id"); + return Response.serverError().entity(jsonBuilder.build()).build(); + } + return Response.seeOther(new URI(returnURL)).build(); + } + + @Path("/token") + @POST + @Produces(APPLICATION_JSON) + public Response tokenEndpoint( + @FormParam(CLIENT_ID) String clientId, + @FormParam(CLIENT_SECRET) String clientSecret, + @FormParam(GRANT_TYPE) String grantType, + @FormParam(CODE) String code, + @FormParam(REDIRECT_URI) String redirectUri) { + + ResponseBuilder builder; + JsonObjectBuilder jsonBuilder = Json.createObjectBuilder(); + if (!CLIENT_ID_VALUE.equals(clientId)) { + jsonBuilder.add(ERROR_PARAM, "invalid_client_id"); + builder = Response.serverError(); + } else if (!CLIENT_SECRET_VALUE.equals(clientSecret)) { + jsonBuilder.add(ERROR_PARAM, "invalid_client_secret"); + builder = Response.serverError(); + } else if (!AUTHORIZATION_CODE.equals(grantType)) { + jsonBuilder.add(ERROR_PARAM, "invalid_grant_type"); + builder = Response.serverError(); + } else if (!AUTH_CODE_VALUE.equals(code)) { + jsonBuilder.add(ERROR_PARAM, "invalid_auth_code"); + builder = Response.serverError(); + } else { + + Date now = new Date(); + JWTClaimsSet.Builder jstClaimsBuilder = new JWTClaimsSet.Builder() + .issuer("http://localhost:8080/openid-server/webresources/oidc-provider-demo") + .subject(getSubject()) + .audience(List.of(CLIENT_ID_VALUE)) + .expirationTime(new Date(now.getTime() + 1000 * 60 * 10)) + .notBeforeTime(now) + .issueTime(now) + .jwtID(UUID.randomUUID().toString()) + .claim(NONCE, nonce); + if (!rolesInUserInfoEndpoint) { + jstClaimsBuilder.claim(OpenIdConstant.GROUPS, userGroups); + } + JWTClaimsSet jwtClaims = jstClaimsBuilder.build(); + + PlainJWT idToken = new PlainJWT(jwtClaims); + jsonBuilder.add(IDENTITY_TOKEN, idToken.serialize()); + jsonBuilder.add(ACCESS_TOKEN, ACCESS_TOKEN_VALUE); + jsonBuilder.add(TOKEN_TYPE, BEARER_TYPE); + jsonBuilder.add(EXPIRES_IN, 1000); + builder = Response.ok(); + } + + return builder.entity(jsonBuilder.build()).build(); + } + + @Path("/userinfo") + @Produces(APPLICATION_JSON) + @GET + public Response userinfoEndpoint(@HeaderParam(AUTHORIZATION_HEADER) String authorizationHeader) { + String accessToken = authorizationHeader.substring(BEARER_TYPE.length() + 1); + + ResponseBuilder builder; + JsonObjectBuilder jsonBuilder = Json.createObjectBuilder(); + if (ACCESS_TOKEN_VALUE.equals(accessToken)) { + builder = Response.ok(); + jsonBuilder.add(SUBJECT_IDENTIFIER, getSubject()) + .add("name", "John") + .add("family_name", "Doe") + .add("given_name", "John Doe") + .add("profile", "https://abc.com/+johnDoe") + .add("picture", "https://abc.com/photo.jpg") + .add("email", "john.doe@acme.org") + .add("email_verified", true) + .add("gender", "male") + .add("locale", "en"); + if (rolesInUserInfoEndpoint) { + JsonArrayBuilder groupsBuilder = Json.createArrayBuilder(); + userGroups.forEach(g -> { + groupsBuilder.add(g); + }); + jsonBuilder.add(OpenIdConstant.GROUPS, groupsBuilder); + } + } else { + jsonBuilder.add(ERROR_PARAM, "invalid_access_token"); + builder = Response.serverError(); + } + return builder.entity(jsonBuilder.build().toString()).build(); + } + + private String getSubject() { + String subjectPrefix = "/subject-"; + return subject != null && subject.startsWith(subjectPrefix) ? subject.substring(subjectPrefix.length()) : "sample_subject"; + } + +} diff --git a/test/app-openid/src/main/resources/openid-configuration.json b/test/app-openid/src/main/resources/openid-configuration.json new file mode 100644 index 0000000..a510708 --- /dev/null +++ b/test/app-openid/src/main/resources/openid-configuration.json @@ -0,0 +1,51 @@ +{ + "issuer": "http://localhost:8080/openid-server/webresources/oidc-provider-demo", + "authorization_endpoint": "http://localhost:8080/openid-server/webresources/oidc-provider-demo/auth", + "token_endpoint": "http://localhost:8080/openid-server/webresources/oidc-provider-demo/token", + "userinfo_endpoint": "http://localhost:8080/openid-server/webresources/oidc-provider-demo/userinfo", + "revocation_endpoint": "http://localhost:8080/openid-server/webresources/oidc-provider-demo/revoke", + "jwks_uri": "http://localhost:8080/openid-server/webresources/oidc-provider-demo/certs", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ] +} \ No newline at end of file diff --git a/test/app-openid/src/main/resources/payara-web.xml b/test/app-openid/src/main/resources/payara-web.xml new file mode 100644 index 0000000..200c105 --- /dev/null +++ b/test/app-openid/src/main/resources/payara-web.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/test/app-openid/src/main/webapp/WEB-INF/beans.xml b/test/app-openid/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java new file mode 100644 index 0000000..2418f0c --- /dev/null +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test; + +import com.gargoylesoftware.htmlunit.WebClient; +import java.io.IOException; +import java.net.URL; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.OperateOnDeployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.shrinkwrap.api.Archive; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * + * @author Gaurav Gupta + * @author Rudy De Busscher + */ + +@RunWith(Arquillian.class) +public class OpenIdDefaultIT { + + private WebClient webClient; + + @OperateOnDeployment("openid-client") + @ArquillianResource + private URL base; + + @Before + public void init() { + webClient = new WebClient(); + } + + @Deployment(name = "openid-server", testable = false) + public static Archive createServerDeployment() { + return OpenIdTestUtil.createServerDeployment(); + } + + @Deployment(name = "openid-client", testable=false) + public static Archive createClientDeployment() { + WebArchive war = OpenIdTestUtil.createClientDefaultDeployment(); + return war; + } + + @Test + @RunAsClient + public void testOpenIdConnect() throws IOException { + OpenIdTestUtil.testOpenIdConnect(webClient, base); + } + +} diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java new file mode 100644 index 0000000..2c84c06 --- /dev/null +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test; + +import com.gargoylesoftware.htmlunit.TextPage; +import com.gargoylesoftware.htmlunit.WebClient; +import org.glassfish.soteria.test.client.Callback; +import org.glassfish.soteria.test.client.GetUserName; +import org.glassfish.soteria.test.client.UnsecuredPage; +import org.glassfish.soteria.test.client.defaulttests.SecuredPage; +import org.glassfish.soteria.test.server.ApplicationConfig; +import org.glassfish.soteria.test.server.OidcProvider; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.jboss.shrinkwrap.resolver.api.maven.Maven; + +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.net.URL; + +import static org.junit.Assert.assertEquals; + +/** + * @author Gaurav Gupta + * @author Jonathan + * @author Rudy De Busscher + */ +public class OpenIdTestUtil { + + public static WebArchive createServerDeployment() { + WebArchive war = ShrinkWrap + .create(WebArchive.class, "openid-server.war") + .addClass(OidcProvider.class) + .addClass(ApplicationConfig.class) + .addAsResource("openid-configuration.json") + .addAsWebInfResource("payara-web.xml") + .addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml"); + return war; + } + + public static WebArchive createClientDeployment() { + + WebArchive war = ShrinkWrap + .create(WebArchive.class, "openid-client.war") + .addClass(Callback.class) + .addClass(UnsecuredPage.class) + .addClass(GetUserName.class) + .addAsWebInfResource("payara-web.xml") + // Always as bundled since it is a newer version! + .addAsLibraries(Maven.resolver() + .loadPomFromFile("pom.xml") + .resolve("org.glassfish.soteria:jakarta.security.enterprise:3.0.0-SNAPSHOT") + .withTransitivity().asFile()) + .addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml"); + + return war; + } + + public static WebArchive createClientDefaultDeployment() { + return createClientDeployment().addClass(SecuredPage.class); + + } + + public static void testOpenIdConnect(WebClient webClient, URL base) throws IOException { + // unsecure page should be accessible for an unauthenticated user + TextPage unsecuredPage = webClient.getPage(base + "Unsecured"); + assertEquals(Response.Status.OK.getStatusCode(), unsecuredPage.getWebResponse().getStatusCode()); + assertEquals("This is an unsecured web page", unsecuredPage.getContent().trim()); + + // access to secured web page authenticates the user and instructs to redirect to the callback URL + TextPage securedPage = webClient.getPage(base + "Secured"); + assertEquals(Response.Status.OK.getStatusCode(), securedPage.getWebResponse().getStatusCode()); + assertEquals(String.format("%sCallback", base.getPath()), securedPage.getUrl().getPath()); + + // access secured web page as an authenticated user + securedPage = webClient.getPage(base + "Secured"); + assertEquals(Response.Status.OK.getStatusCode(), securedPage.getWebResponse().getStatusCode()); + assertEquals("This is a secured web page", securedPage.getContent().trim()); + + // finally, access should still be allowed to an unsecured web page when already logged in + unsecuredPage = webClient.getPage(base + "Unsecured"); + assertEquals(Response.Status.OK.getStatusCode(), unsecuredPage.getWebResponse().getStatusCode()); + assertEquals("This is an unsecured web page", unsecuredPage.getContent().trim()); + } + +} diff --git a/test/pom.xml b/test/pom.xml index 19cefc0..9571cf5 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -55,6 +55,7 @@ app-custom app-multiple-store app-multiple-store-backup + app-openid app-mem-basic From 804ad0f4e391bfe5ef137043f7677ea7e6fba189 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Sun, 17 Oct 2021 12:17:40 +0200 Subject: [PATCH 5/7] Some more tests related to OpenId. --- .../client/defaulttests/OpenIdConfig.java | 72 +++++++++++++++ .../test/client/defaulttests/SecuredPage.java | 4 +- .../defaulttests/SecuredPageWithEL.java | 51 +++++++++++ .../soteria/test/server/OidcProvider.java | 1 - .../soteria/test/InvalidRedirectURIIT.java | 88 +++++++++++++++++++ .../soteria/test/OpenIdDefaultIT.java | 4 +- .../soteria/test/OpenIdTestUtil.java | 9 +- .../soteria/test/OpenIdWithELIT.java | 71 +++++++++++++++ 8 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/OpenIdConfig.java create mode 100644 test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPageWithEL.java create mode 100644 test/app-openid/src/test/java/org/glassfish/soteria/test/InvalidRedirectURIIT.java create mode 100644 test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdWithELIT.java diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/OpenIdConfig.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/OpenIdConfig.java new file mode 100644 index 0000000..90df0d6 --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/OpenIdConfig.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + */ +package org.glassfish.soteria.test.client.defaulttests; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Named; +import org.glassfish.soteria.test.server.OidcProvider; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +@Named +@Dependent +public class OpenIdConfig { + + public static final String OPEN_ID_CONFIG_PROPERTIES = "/openIdConfig.properties"; + public static final String REDIRECT_URI = "redirectURI"; + public static final String CLIENT_ID = "clientId"; + public static final String CLIENT_SECRET = "clientSecret"; + + private Properties config; + + @PostConstruct + public void init() { + + config = new Properties(); + + InputStream configFile = OpenIdConfig.class.getResourceAsStream(OPEN_ID_CONFIG_PROPERTIES); + if (configFile != null) { + try { + config.load(configFile); + } catch (IOException e) { + throw new IllegalStateException("Could not load OpenIdConfig"); + } + } + } + + public String getRedirectURI() { + if (config.containsKey(REDIRECT_URI)) { + return config.getProperty(REDIRECT_URI); + } + return "${baseURL}/Callback"; + } + + public String getClientId() { + if (config.containsKey(CLIENT_ID)) { + return config.getProperty(CLIENT_ID); + } + return OidcProvider.CLIENT_ID_VALUE; + } + + public String getClientSecret() { + if (config.containsKey(CLIENT_SECRET)) { + return config.getProperty(CLIENT_SECRET); + } + return OidcProvider.CLIENT_SECRET_VALUE; + } +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java index 2fd16fa..4c01e58 100644 --- a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPage.java @@ -16,8 +16,8 @@ */ package org.glassfish.soteria.test.client.defaulttests; -import static org.glassfish.soteria.test.server.OidcProvider .CLIENT_ID_VALUE; -import static org.glassfish.soteria.test.server.OidcProvider .CLIENT_SECRET_VALUE; +import static org.glassfish.soteria.test.server.OidcProvider.CLIENT_ID_VALUE; +import static org.glassfish.soteria.test.server.OidcProvider.CLIENT_SECRET_VALUE; import java.io.IOException; import jakarta.annotation.security.DeclareRoles; import jakarta.security.enterprise.identitystore.OpenIdAuthenticationDefinition; diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPageWithEL.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPageWithEL.java new file mode 100644 index 0000000..ff1874d --- /dev/null +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/client/defaulttests/SecuredPageWithEL.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test.client.defaulttests; + +import jakarta.annotation.security.DeclareRoles; +import jakarta.security.enterprise.identitystore.OpenIdAuthenticationDefinition; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.HttpConstraint; +import jakarta.servlet.annotation.ServletSecurity; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ +@WebServlet("/Secured") +@OpenIdAuthenticationDefinition( + providerURI = "http://localhost:8080/openid-server/webresources/oidc-provider-demo", + clientId = "${openIdConfig.clientId}", + clientSecret = "${openIdConfig.clientSecret}", + redirectURI = "${openIdConfig.redirectURI}" +) +@DeclareRoles("all") +@ServletSecurity(@HttpConstraint(rolesAllowed = "all")) +public class SecuredPageWithEL extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.getWriter().println("This is a secured web page"); + } +} diff --git a/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java index e9732d8..545ea95 100644 --- a/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java +++ b/test/app-openid/src/main/java/org/glassfish/soteria/test/server/OidcProvider.java @@ -41,7 +41,6 @@ import static jakarta.security.enterprise.identitystore.openid.OpenIdConstant.*; import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; -import static java.util.Arrays.asList; import static java.util.logging.Level.SEVERE; import static java.util.stream.Collectors.joining; diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/InvalidRedirectURIIT.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/InvalidRedirectURIIT.java new file mode 100644 index 0000000..0ba8b3e --- /dev/null +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/InvalidRedirectURIIT.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * Contributors: + * 2021 : Payara Foundation and/or its affiliates + */ +package org.glassfish.soteria.test; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.WebClient; +import org.glassfish.soteria.test.client.defaulttests.OpenIdConfig; +import org.glassfish.soteria.test.client.defaulttests.SecuredPageWithEL; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.OperateOnDeployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.shrinkwrap.api.Archive; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.URL; + +import static javax.ws.rs.core.Response.Status.NOT_FOUND; +import static org.glassfish.soteria.test.client.defaulttests.OpenIdConfig.OPEN_ID_CONFIG_PROPERTIES; +import static org.glassfish.soteria.test.client.defaulttests.OpenIdConfig.REDIRECT_URI; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * @author Gaurav Gupta + * @author Rudy De Busscher + */ + +@RunWith(Arquillian.class) +public class InvalidRedirectURIIT { + + private WebClient webClient; + + @OperateOnDeployment("openid-client") + @ArquillianResource + private URL base; + + @Before + public void init() { + webClient = new WebClient(); + } + + @Deployment(name = "openid-server", testable = false) + public static Archive createServerDeployment() { + return OpenIdTestUtil.createServerDeployment(); + } + + @Deployment(name = "openid-client", testable = false) + public static Archive createClientDeployment() { + StringAsset config = new StringAsset(REDIRECT_URI + "=invalid_callback"); + WebArchive war = OpenIdTestUtil.createClientDeployment(SecuredPageWithEL.class, OpenIdConfig.class) + .addAsWebInfResource(config, "classes" + OPEN_ID_CONFIG_PROPERTIES); + System.out.println(war.toString(true)); + return war; + } + + @Test + @RunAsClient + public void testOpenIdConnect() throws IOException { + try { + webClient.getPage(base + "Secured"); + fail("redirect uri is valid"); + } catch (FailingHttpStatusCodeException ex) { + assertEquals(NOT_FOUND.getStatusCode(), ex.getStatusCode()); + } + } + +} diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java index 2418f0c..55fd59f 100644 --- a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdDefaultIT.java @@ -19,6 +19,8 @@ import com.gargoylesoftware.htmlunit.WebClient; import java.io.IOException; import java.net.URL; + +import org.glassfish.soteria.test.client.defaulttests.SecuredPage; import org.jboss.arquillian.container.test.api.Deployment; import org.jboss.arquillian.container.test.api.OperateOnDeployment; import org.jboss.arquillian.container.test.api.RunAsClient; @@ -57,7 +59,7 @@ public static Archive createServerDeployment() { @Deployment(name = "openid-client", testable=false) public static Archive createClientDeployment() { - WebArchive war = OpenIdTestUtil.createClientDefaultDeployment(); + WebArchive war = OpenIdTestUtil.createClientDeployment(SecuredPage.class); return war; } diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java index 2c84c06..96707a9 100644 --- a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdTestUtil.java @@ -21,7 +21,6 @@ import org.glassfish.soteria.test.client.Callback; import org.glassfish.soteria.test.client.GetUserName; import org.glassfish.soteria.test.client.UnsecuredPage; -import org.glassfish.soteria.test.client.defaulttests.SecuredPage; import org.glassfish.soteria.test.server.ApplicationConfig; import org.glassfish.soteria.test.server.OidcProvider; import org.jboss.shrinkwrap.api.ShrinkWrap; @@ -53,13 +52,14 @@ public static WebArchive createServerDeployment() { return war; } - public static WebArchive createClientDeployment() { + public static WebArchive createClientDeployment(Class... additionalClasses) { WebArchive war = ShrinkWrap .create(WebArchive.class, "openid-client.war") .addClass(Callback.class) .addClass(UnsecuredPage.class) .addClass(GetUserName.class) + .addClasses(additionalClasses) .addAsWebInfResource("payara-web.xml") // Always as bundled since it is a newer version! .addAsLibraries(Maven.resolver() @@ -71,11 +71,6 @@ public static WebArchive createClientDeployment() { return war; } - public static WebArchive createClientDefaultDeployment() { - return createClientDeployment().addClass(SecuredPage.class); - - } - public static void testOpenIdConnect(WebClient webClient, URL base) throws IOException { // unsecure page should be accessible for an unauthenticated user TextPage unsecuredPage = webClient.getPage(base + "Unsecured"); diff --git a/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdWithELIT.java b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdWithELIT.java new file mode 100644 index 0000000..d78f90c --- /dev/null +++ b/test/app-openid/src/test/java/org/glassfish/soteria/test/OpenIdWithELIT.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2021 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + */ +package org.glassfish.soteria.test; + +import com.gargoylesoftware.htmlunit.WebClient; +import org.glassfish.soteria.test.client.defaulttests.OpenIdConfig; +import org.glassfish.soteria.test.client.defaulttests.SecuredPage; +import org.glassfish.soteria.test.client.defaulttests.SecuredPageWithEL; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.OperateOnDeployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.shrinkwrap.api.Archive; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.URL; + +/** + * + * @author Rudy De Busscher + */ + +@RunWith(Arquillian.class) +public class OpenIdWithELIT { + + private WebClient webClient; + + @OperateOnDeployment("openid-client") + @ArquillianResource + private URL base; + + @Before + public void init() { + webClient = new WebClient(); + } + + @Deployment(name = "openid-server", testable = false) + public static Archive createServerDeployment() { + return OpenIdTestUtil.createServerDeployment(); + } + + @Deployment(name = "openid-client", testable=false) + public static Archive createClientDeployment() { + WebArchive war = OpenIdTestUtil.createClientDeployment(SecuredPageWithEL.class, OpenIdConfig.class); + return war; + } + + @Test + @RunAsClient + public void testOpenIdConnect() throws IOException { + OpenIdTestUtil.testOpenIdConnect(webClient, base); + } + +} From 4c070727fdeb85637fe33ad68330501c8c3ec48e Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Fri, 12 Nov 2021 15:10:50 +0100 Subject: [PATCH 6/7] Fix merge issues --- pom.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 12eb1bd..f3840bd 100644 --- a/pom.xml +++ b/pom.xml @@ -77,8 +77,7 @@ UTF-8 UTF-8 - 11 - 11 + 11 From 510a6b79cfaf24d468da2189b702a488d2962c45 Mon Sep 17 00:00:00 2001 From: Rudy De Busscher Date: Sun, 16 Jan 2022 14:30:10 +0100 Subject: [PATCH 7/7] Sign off with correct email address Signed-off-by: Rudy De Busscher --- pom.xml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index f3840bd..e3d311e 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,8 @@ --> - + 4.0.0 @@ -156,7 +157,7 @@ 4.13.1 test - + @@ -209,7 +210,7 @@ - + org.apache.maven.plugins @@ -230,7 +231,7 @@ - + org.apache.maven.plugins @@ -255,7 +256,7 @@ test - + only-eclipse @@ -285,7 +286,7 @@ - + @@ -299,11 +300,11 @@ - + - + org.apache.maven.plugins maven-dependency-plugin [3.1.0,) @@ -312,7 +313,7 @@ - +