From 1a2ff9976c94ef03946cc6b1dea2bdb146d22daa Mon Sep 17 00:00:00 2001 From: Vadim Vertinskiy Date: Mon, 6 Oct 2025 15:14:41 +0300 Subject: [PATCH 1/3] #46: Implementation of bootique-shiro-web-oidconnect --- .gitignore | 1 + .../jwt/JwtBearerAuthenticationFilter.java | 14 +- .../shiro/web/jwt/ShiroWebJwtModule.java | 2 +- .../web/jwt/ShiroWebJwtModuleFactory.java | 4 + bootique-shiro-web-oidconnect/pom.xml | 103 ++++++++++ .../oidconnect/JwtOpenIdCallbackHandler.java | 185 ++++++++++++++++++ .../shiro/web/oidconnect/OidConnect.java | 30 +++ .../web/oidconnect/OidConnectFilter.java | 62 ++++++ .../shiro/web/oidconnect/OidConnectUtils.java | 85 ++++++++ .../oidconnect/ShiroWebOidConnectModule.java | 74 +++++++ .../ShiroWebOidConnectModuleFactory.java | 132 +++++++++++++ .../META-INF/services/io.bootique.BQModule | 1 + .../JwtOpenIdConnectCallbackHandlerIT.java | 135 +++++++++++++ .../web/oidconnect/OidConnectBaseTest.java | 35 ++++ .../web/oidconnect/OidConnectFilterIT.java | 113 +++++++++++ .../OidConnectFilterInvalidGrantIT.java | 88 +++++++++ .../OidConnectFilterUnauthorizedIT.java | 81 ++++++++ .../shiro/web/oidconnect/TokenServer.java | 4 + .../shiro/web/oidconnect/jwks-private-key.pem | 28 +++ .../bootique/shiro/web/oidconnect/jwks.json | 12 ++ .../shiro/web/oidconnect/oidconnect.yml | 28 +++ pom.xml | 1 + 22 files changed, 1216 insertions(+), 2 deletions(-) create mode 100644 bootique-shiro-web-oidconnect/pom.xml create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnect.java create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectFilter.java create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java create mode 100644 bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java create mode 100644 bootique-shiro-web-oidconnect/src/main/resources/META-INF/services/io.bootique.BQModule create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectBaseTest.java create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterUnauthorizedIT.java create mode 100644 bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/TokenServer.java create mode 100644 bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks-private-key.pem create mode 100644 bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks.json create mode 100644 bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/oidconnect.yml diff --git a/.gitignore b/.gitignore index 99918ef..3054ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ target *.iml .idea derby.log +*46 diff --git a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/JwtBearerAuthenticationFilter.java b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/JwtBearerAuthenticationFilter.java index 3ee85a8..9987afb 100644 --- a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/JwtBearerAuthenticationFilter.java +++ b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/JwtBearerAuthenticationFilter.java @@ -68,7 +68,7 @@ protected boolean executeLogin(ServletRequest request, ServletResponse response) try { return super.executeLogin(request, response); } catch (JwtException | AuthenticationException e) { - WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + redirectIfNoAuth(request, response, e); return false; } } @@ -80,4 +80,16 @@ private void validateAudience(Set audienceJwtClaim) { } } } + + protected void redirectIfNoAuth(ServletRequest request, ServletResponse response, Exception e) throws Exception { + if (e == null) { + WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED); + } else { + WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + } + } + + protected void redirectIfNoAuth(ServletRequest request, ServletResponse response) throws Exception { + redirectIfNoAuth(request, response, null); + } } diff --git a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModule.java b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModule.java index db4409b..23c035b 100644 --- a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModule.java +++ b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModule.java @@ -35,7 +35,7 @@ */ public class ShiroWebJwtModule implements BQModule { - private static final String CONFIG_PREFIX = "shirowebjwt"; + public static final String CONFIG_PREFIX = "shirowebjwt"; private static final String JWT_BEARER_AUTHENTICATION_FILTER_NAME = "jwtBearer"; @Override diff --git a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModuleFactory.java b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModuleFactory.java index eeb012a..c19160c 100644 --- a/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModuleFactory.java +++ b/bootique-shiro-web-jwt/src/main/java/io/bootique/shiro/web/jwt/ShiroWebJwtModuleFactory.java @@ -80,6 +80,10 @@ public JwtBearerAuthenticationFilter createFilter(Provider tokenParse return new JwtBearerAuthenticationFilter(tokenParser, this.audience); } + public String provideAudience() { + return this.audience; + } + private AuthzReaderFactory getRoles() { return roles != null ? roles : new JsonListAuthzReaderFactory(); } diff --git a/bootique-shiro-web-oidconnect/pom.xml b/bootique-shiro-web-oidconnect/pom.xml new file mode 100644 index 0000000..71e0df7 --- /dev/null +++ b/bootique-shiro-web-oidconnect/pom.xml @@ -0,0 +1,103 @@ + + + + + + 4.0.0 + + io.bootique.shiro + bootique-shiro-parent + 4.0-SNAPSHOT + + + bootique-shiro-web-oidconnect + jar + + bootique-shiro-web-oidconnect: Bootique Shiro OpenID Connect + Integration of OpenID Connect to Bootique Shiro + + + + + io.bootique.jersey + bootique-jersey + ${project.version} + + + io.bootique.jetty + bootique-jetty-junit5 + ${project.version} + + + + + + + + io.bootique.shiro + bootique-shiro-web-jwt + ${project.version} + + + io.bootique.jersey + bootique-jersey + + + + org.mockito + mockito-core + test + + + io.bootique.jetty + bootique-jetty-junit5 + test + + + org.slf4j + slf4j-simple + test + + + + + + + gpg + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + + + + diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java new file mode 100644 index 0000000..03b2958 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java @@ -0,0 +1,185 @@ +package io.bootique.shiro.web.oidconnect; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletRequest; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.*; +import org.eclipse.jetty.http.HttpStatus; +import org.glassfish.jersey.client.JerseyClient; +import org.glassfish.jersey.client.JerseyClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +@Path("/bq-shiro-oauth-callback") +public class JwtOpenIdCallbackHandler implements OidConnect { + + private static final Logger LOGGER = LoggerFactory.getLogger(JwtOpenIdCallbackHandler.class); + + private final ObjectMapper mapper; + + private final JerseyClient webClient; + private final WebTarget tokenTarget; + private final String tokenCookie; + private final String clientId; + private final String clientSecretKey; + private final String oidpUrl; + private final String callbackUri; + private final String scope; + + public JwtOpenIdCallbackHandler(ObjectMapper objectMapper, + String tokenCookie, + String tokenUrl, + String clientId, + String clientSecretKey, + String scope, + String oidpUrl, + String callbackUri) { + this.mapper = objectMapper; + this.tokenCookie = tokenCookie; + this.clientId = clientId; + this.clientSecretKey = URLEncoder.encode(clientSecretKey, StandardCharsets.UTF_8); + this.scope = scope; + this.webClient = JerseyClientBuilder.createClient(); + this.tokenTarget = webClient.target(tokenUrl); + this.oidpUrl = oidpUrl; + this.callbackUri = callbackUri; + } + + private Form form(String code) { + Form form = new Form() + .param(GRANT_TYPE_PARAMETER_NAME, GRANT_TYPE_AUTH_CODE_VALUE) + .param(CLIENT_ID_PARAMETER_NAME, clientId) + .param(CLIENT_SECRET_KEY_PARAMETER_NAME, clientSecretKey) + .param(CODE_PARAMETER_NAME, code); + if (scope != null && !scope.isEmpty()) { + form = form.param(SCOPE_PARMETER_NAME, scope); + } + return form; + } + + @GET + public Response callback(@Context UriInfo uriInfo) { + // 1. Read query parameters + MultivaluedMap params = uriInfo.getQueryParameters(); + String code = params.getFirst(CODE_PARAMETER_NAME); + String originalUri = params.getFirst(ORIGINAL_URI_PARAMETER_NAME); + String state = params.getFirst(STATE_PARAMETER_NAME); + // 2. Validate parameters + ErrorHandler errorHandler = validateRequiredParameters(code, state); + if (errorHandler.hasError()) { + return Response.status(Response.Status.BAD_REQUEST).entity(errorHandler.get()).build(); + } + // 3. Exchange auth code to token on JWT server + Response tokenResponse = exchange(code); + try { + if (tokenResponse.getStatus() == HttpStatus.OK_200) { + JsonNode json = mapper.readTree(tokenResponse.readEntity(String.class)); + // 4. Push token to cookie + String token = json.get(ACCESS_TOKEN_PARAMETER_NAME).asText(); + // 5. Redirect to "redirectUrl" if defined + if (originalUri != null && !originalUri.isEmpty()) { + WebTarget redirectTarget = prepareOriginalTarget(uriInfo.getBaseUri(), originalUri); + return redirectTarget.request().cookie(tokenCookie, token).get(); + } else { + return Response.ok() + .cookie(new NewCookie.Builder(tokenCookie).value(token).build()) + .build(); + } + } else { + JsonNode json = mapper.readTree(tokenResponse.readEntity(String.class)); + JsonNode error = json.get(ERROR_PARAMETER_NAME); + if (error != null && error.isTextual()) { + String errorCode = error.asText(); + if (INVALID_GRANT_ERROR_CODE.equals(errorCode)) { + String oidpUrl = this.oidpUrl + "?" + OidConnectUtils.getOidpParametersString(uriInfo.getBaseUri().toString(), originalUri, clientId, callbackUri, true); + LOGGER.warn("Auth server returns error code " + INVALID_GRANT_ERROR_CODE + ". Redirection to oidp URL " + oidpUrl); + return Response.status(Response.Status.FOUND).header(LOCATION_HEADER_NAME, oidpUrl).build(); + } else { + LOGGER.warn("Auth server returns error code " + errorCode + ". Unauthorized"); + return Response.status(Response.Status.UNAUTHORIZED.getStatusCode(), "Auth server error: " + errorCode).build(); + } + } + + } + } catch (Exception e) { + LOGGER.error("Some internal error is happened", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Some internal error is happened").build(); + } + return tokenResponse; + } + + private WebTarget prepareOriginalTarget(URI baseUri, String encodedOriginalUri) { + // 1. Decode original uri + URI originalUri = URI.create(Base64Coder.decodeString(URLDecoder.decode(encodedOriginalUri, StandardCharsets.UTF_8))); + // 2. Parse path + WebTarget redirectTarget = + webClient.target(baseUri).path(originalUri.getPath()); + // 3. Parse params + String query = originalUri.getQuery(); + if (query != null && !query.isEmpty()) { + String[] params = query.split("&"); + for (String p : params) { + String[] pair = p.split("="); + redirectTarget = redirectTarget.queryParam(pair[0], pair[1]); + } + } + return redirectTarget; + } + + private ErrorHandler validateRequiredParameters(String code, String state) { + ErrorHandler errorHandler = new ErrorHandler(); + boolean hasCode = code != null && !code.isEmpty(); + boolean hasState = state != null && !state.isEmpty(); + if (!hasCode && !hasState) { + errorHandler.append("Parameters \"code\" and \"state\" are required"); + } else if (!hasCode) { + errorHandler.append("Parameter \"code\" is required"); + } else if (!hasState) { + errorHandler.append("Parameter \"state\" is required"); + } + return errorHandler; + } + + private Response exchange(String code) { + Entity
postForm = Entity.form(form(code)); + return tokenTarget + .request() + .post(postForm); + } + + private static class ErrorHandler { + + private String error; + + void append(String error) { + if (this.error == null || this.error.isEmpty()) { + this.error = error; + } else { + this.error += "\n" + error; + } + } + + String get() { + return this.error; + } + + boolean hasError() { + return this.error != null && !this.error.isEmpty(); + } + } +} diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnect.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnect.java new file mode 100644 index 0000000..0a79a92 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnect.java @@ -0,0 +1,30 @@ +package io.bootique.shiro.web.oidconnect; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.shiro.web.util.WebUtils; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +public interface OidConnect { + + String RESPONSE_TYPE_PARAMETER_NAME = "response_type"; + String CLIENT_ID_PARAMETER_NAME = "client_id"; + String REDIRECT_URI_PARAMETER_NAME = "redirect_uri"; + String STATE_PARAMETER_NAME = "state"; + String ORIGINAL_URI_PARAMETER_NAME = "original_uri"; + String CODE_PARAMETER_NAME = "code"; + String CLIENT_SECRET_KEY_PARAMETER_NAME = "client_secret"; + String GRANT_TYPE_PARAMETER_NAME = "grant_type"; + String GRANT_TYPE_AUTH_CODE_VALUE = "authorization_code"; + String SCOPE_PARMETER_NAME = "scope"; + String ACCESS_TOKEN_PARAMETER_NAME = "access_token"; + String ERROR_PARAMETER_NAME = "error"; + String INVALID_GRANT_ERROR_CODE = "invalid_grant"; + String LOCATION_HEADER_NAME = "Location"; +} diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectFilter.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectFilter.java new file mode 100644 index 0000000..deb3a2e --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectFilter.java @@ -0,0 +1,62 @@ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.shiro.web.jwt.JwtBearerAuthenticationFilter; +import io.jsonwebtoken.JwtParser; +import jakarta.inject.Provider; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.shiro.web.util.WebUtils; + +import java.util.Arrays; + +public class OidConnectFilter extends JwtBearerAuthenticationFilter implements OidConnect { + + private final String oidpUrl; + private final String tokenCookie; + private final String clientId; + private final String callbackUri; + + public OidConnectFilter(Provider tokenParser, + String audience, + String oidpUrl, + String tokenCookie, + String clientId, + String callbackUri) { + super(tokenParser, audience); + this.oidpUrl = oidpUrl; + this.tokenCookie = tokenCookie; + this.clientId = clientId; + this.callbackUri = callbackUri; + } + + @Override + protected String getAuthzHeader(ServletRequest request) { + HttpServletRequest httpRequest = WebUtils.toHttp(request); + Cookie[] cookies = httpRequest.getCookies(); + return cookies == null ? null : Arrays.stream(cookies) + .filter(c -> c.getName().equals(tokenCookie)) + .findFirst() + .map(c -> "Bearer " + c.getValue()) + .orElse(null); + } + + protected void redirectIfNoAuth(ServletRequest request, ServletResponse response, Exception e) throws Exception { + redirectToOpenIdLoginPage(request, response); + } + + private void redirectToOpenIdLoginPage(ServletRequest request, ServletResponse response) throws Exception { + WebUtils.issueRedirect(request, response, oidpUrl, OidConnectUtils.getOidpParametersMap(request, clientId, callbackUri)); + } + + @Override + protected boolean sendChallenge(ServletRequest request, ServletResponse response) { + try { + redirectToOpenIdLoginPage(request, response); + return false; + } catch (Exception e) { + return super.sendChallenge(request, response); + } + } +} diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java new file mode 100644 index 0000000..e6a5c5f --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java @@ -0,0 +1,85 @@ +package io.bootique.shiro.web.oidconnect; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import org.apache.shiro.web.util.WebUtils; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +public class OidConnectUtils { + + static String getOidpParametersString(String baseUri, String originalUri, String clientId, String callbackUri, boolean invalidGrant) { + StringBuilder params = new StringBuilder() + .append(OidConnect.RESPONSE_TYPE_PARAMETER_NAME).append("=").append(OidConnect.CODE_PARAMETER_NAME) + .append("&").append(OidConnect.CLIENT_ID_PARAMETER_NAME).append("=").append(clientId) + .append("&").append(OidConnect.REDIRECT_URI_PARAMETER_NAME).append("=").append(getCallbackUri(baseUri, originalUri, callbackUri)) + .append("&").append(OidConnect.STATE_PARAMETER_NAME).append("=").append(getState(invalidGrant)); + return params.toString(); + } + + static Map getOidpParametersMap(String baseUri, String originalUri, String clientId, String callbackUri) { + return new HashMap<>() { + { + put(OidConnect.RESPONSE_TYPE_PARAMETER_NAME, OidConnect.CODE_PARAMETER_NAME); + put(OidConnect.CLIENT_ID_PARAMETER_NAME, clientId); + put(OidConnect.REDIRECT_URI_PARAMETER_NAME, getCallbackUri(baseUri, originalUri, callbackUri)); + put(OidConnect.STATE_PARAMETER_NAME, getState(false)); + } + }; + } + + private static String getState(boolean invalidGrant) { + // TODO: How do we generate it + if (invalidGrant) { + return OidConnect.INVALID_GRANT_ERROR_CODE; + } + return Response.Status.OK.getReasonPhrase(); + } + + static Map getOidpParametersMap(ServletRequest servletRequest, String clientId, String callbackUri) { + return new HashMap<>() { + { + put(OidConnect.RESPONSE_TYPE_PARAMETER_NAME, OidConnect.CODE_PARAMETER_NAME); + put(OidConnect.CLIENT_ID_PARAMETER_NAME, clientId); + put(OidConnect.REDIRECT_URI_PARAMETER_NAME, getCallbackUri(servletRequest, callbackUri)); + put(OidConnect.STATE_PARAMETER_NAME, getState(false)); + } + }; + } + + static String getCallbackUri(String baseUri, String originalUri, String callbackUri) { + if (originalUri != null && !originalUri.isEmpty()) { + return baseUri + callbackUri + "?" + OidConnect.ORIGINAL_URI_PARAMETER_NAME + "=" + URLEncoder.encode( + new String(Base64.getEncoder().encode(originalUri.getBytes())), StandardCharsets.UTF_8); + } else { + return baseUri + callbackUri; + } + } + + static String getCallbackUri(ServletRequest request, String callbackUri) { + HttpServletRequest servletRequest = WebUtils.toHttp(request); + String baseUri = servletRequest.getRequestURL() + .substring(0, servletRequest.getRequestURL().length() - servletRequest.getRequestURI().length()) + + servletRequest.getContextPath(); + StringBuilder originalUri = new StringBuilder(); + Enumeration parameters = servletRequest.getParameterNames(); + while (parameters.hasMoreElements()) { + String parameter = parameters.nextElement(); + if (originalUri.isEmpty()) { + originalUri.append(servletRequest.getRequestURI()).append("?"); + } else { + originalUri.append("&"); + } + originalUri.append(parameter).append("=").append(servletRequest.getParameter(parameter)); + } + if (originalUri.isEmpty()) { + originalUri.append(servletRequest.getRequestURI()); + } + return baseUri + callbackUri + "?" + OidConnect.ORIGINAL_URI_PARAMETER_NAME + "=" + URLEncoder.encode( + new String(Base64.getEncoder().encode(originalUri.toString().getBytes())), StandardCharsets.UTF_8); + } +} diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java new file mode 100644 index 0000000..3f3c6d6 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java @@ -0,0 +1,74 @@ +/* + * Licensed to ObjectStyle LLC under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ObjectStyle LLC licenses + * this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.bootique.shiro.web.oidconnect; + +import io.bootique.BQModule; +import io.bootique.ModuleCrate; +import io.bootique.config.ConfigurationFactory; +import io.bootique.di.Binder; +import io.bootique.di.Provides; +import io.bootique.jackson.JacksonService; +import io.bootique.jersey.JerseyModule; +import io.bootique.shiro.web.ShiroWebModule; +import io.bootique.shiro.web.jwt.ShiroWebJwtModule; +import io.bootique.shiro.web.jwt.ShiroWebJwtModuleFactory; +import io.jsonwebtoken.JwtParser; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; + +/** + * @since 4.0 + */ +public class ShiroWebOidConnectModule implements BQModule { + + private static final String CONFIG_PREFIX = "shiroweboidconnect"; + private static final String OID_CONNECT_BEARER_AUTHENTICATION_FILTER_NAME = "jwtBearerOidConnect"; + + @Override + public ModuleCrate crate() { + return ModuleCrate.of(this) + .description("Integrates OpenId Connect to Shiro") + .config(CONFIG_PREFIX, ShiroWebOidConnectModuleFactory.class) + .build(); + } + + @Override + public void configure(Binder binder) { + ShiroWebModule.extend(binder).setFilter(OID_CONNECT_BEARER_AUTHENTICATION_FILTER_NAME, OidConnectFilter.class); + JerseyModule.extend(binder).addResource(JwtOpenIdCallbackHandler.class); + } + + @Provides + @Singleton + public OidConnectFilter provideOidConnectFilter(ConfigurationFactory configFactory, + Provider jwtParser) { + String audience = configFactory.config(ShiroWebJwtModuleFactory.class, ShiroWebJwtModule.CONFIG_PREFIX).provideAudience(); + return configFactory.config(ShiroWebOidConnectModuleFactory.class, CONFIG_PREFIX).createFilter(jwtParser, audience); + } + + @Provides + @Singleton + public JwtOpenIdCallbackHandler provideOpenIdCallbackHandler(ConfigurationFactory configFactory, + JacksonService jacksonService) { + String audience = configFactory.config(ShiroWebJwtModuleFactory.class, ShiroWebJwtModule.CONFIG_PREFIX).provideAudience(); + return configFactory.config(ShiroWebOidConnectModuleFactory.class, CONFIG_PREFIX) + .createJwtOpenIdCallbackHandler(jacksonService, audience); + } +} diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java new file mode 100644 index 0000000..9d6fd0b --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java @@ -0,0 +1,132 @@ +/* + * Licensed to ObjectStyle LLC under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ObjectStyle LLC licenses + * this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.annotation.BQConfig; +import io.bootique.annotation.BQConfigProperty; +import io.bootique.jackson.JacksonService; +import io.bootique.resource.ResourceFactory; +import io.bootique.shiro.web.jwt.jjwt.JwtParserMaker; +import io.bootique.shiro.web.jwt.authz.AuthzReaderFactory; +import io.bootique.shiro.web.jwt.authz.JsonListAuthzReaderFactory; +import io.bootique.value.Duration; +import io.jsonwebtoken.JwtParser; +import jakarta.inject.Provider; + +import java.net.URI; +import java.net.URL; +import java.util.Objects; + +/** + * @since 4.0 + */ +@BQConfig("OpenID Connect Configuration") +public class ShiroWebOidConnectModuleFactory { + + private static final String DEFAULT_TOKEN_COOKIE = "bq-shiro-oid"; + private static final String DEFAULT_CALLBACK_URL = "/bq-shiro-oauth-callback"; + + private String oidpUrl; + private String tokenUrl; + private String clientId; + private String clientSecret; + private String tokenCookie; + private String callbackUrl; + + + @BQConfigProperty("OpenId Connect Login Url") + public void setOidpUrl(String oidpUrl) { + this.oidpUrl = oidpUrl; + } + + @BQConfigProperty("JWT Token Url") + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + + @BQConfigProperty("Client Id") + public void setClientId(String clientId) { + this.clientId = clientId; + } + + @BQConfigProperty("Client Secret") + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + @BQConfigProperty("Token Cookie Name") + public void setTokenCookie(String tokenCookie) { + this.tokenCookie = tokenCookie; + } + + @BQConfigProperty("Callback Url") + public void setCallbackUrl(String callbackUrl) { + this.callbackUrl = callbackUrl; + } + + private String getOidpUrl() { + if (this.oidpUrl == null || this.oidpUrl.isEmpty()) { + throw new IllegalArgumentException("OpenId Connect Login Url property is not defined"); + } + return this.oidpUrl; + } + + private String getTokenUrl() { + if (this.tokenUrl == null || this.tokenUrl.isEmpty()) { + throw new IllegalArgumentException("Token Url property is not defined"); + } + return this.tokenUrl; + } + + private String getTokenCookie() { + if (this.tokenCookie == null || tokenCookie.isEmpty()) { + return DEFAULT_TOKEN_COOKIE; + } + return this.tokenCookie; + } + + private String getClientId() { + if (this.clientId == null || this.clientId.isEmpty()) { + throw new IllegalArgumentException("Client Id property is not defined"); + } + return this.clientId; + } + + private String getClientSecret() { + if (this.clientSecret == null || this.clientSecret.isEmpty()) { + throw new IllegalArgumentException("Client Secret property is not defined"); + } + return this.clientSecret; + } + + private String getCallbackUrl() { + if (this.callbackUrl == null || callbackUrl.isEmpty()) { + return DEFAULT_CALLBACK_URL; + } + return this.callbackUrl; + } + + public OidConnectFilter createFilter(Provider tokenParser, String audience) { + return new OidConnectFilter(tokenParser, audience, getOidpUrl(), getTokenCookie(), getClientId(), getCallbackUrl()); + } + + public JwtOpenIdCallbackHandler createJwtOpenIdCallbackHandler(JacksonService jacksonService, String audience) { + return new JwtOpenIdCallbackHandler(jacksonService.newObjectMapper(), getTokenCookie(), getTokenUrl(), getClientId(), getClientSecret(), audience, getOidpUrl(), getCallbackUrl()); + } +} diff --git a/bootique-shiro-web-oidconnect/src/main/resources/META-INF/services/io.bootique.BQModule b/bootique-shiro-web-oidconnect/src/main/resources/META-INF/services/io.bootique.BQModule new file mode 100644 index 0000000..783f6b5 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/main/resources/META-INF/services/io.bootique.BQModule @@ -0,0 +1 @@ +io.bootique.shiro.web.oidconnect.ShiroWebOidConnectModule \ No newline at end of file diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java new file mode 100644 index 0000000..a97daad --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java @@ -0,0 +1,135 @@ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.BQCoreModule; +import io.bootique.BQRuntime; +import io.bootique.Bootique; +import io.bootique.jersey.JerseyModule; +import io.bootique.jetty.JettyModule; +import io.bootique.jetty.junit5.JettyTester; +import io.bootique.junit5.BQApp; +import io.bootique.junit5.BQTest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; +import org.junit.jupiter.api.Test; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +@BQTest +public class JwtOpenIdConnectCallbackHandlerIT extends OidConnectBaseTest { + + private final JettyTester jetty = JettyTester.create(); + + private static final JettyTester serverJetty = JettyTester.create(); + + + @BQApp + static final BQRuntime tokenServerApp = Bootique.app("-s") + .module(JettyModule.class) + .module(JerseyModule.class) + .module(serverJetty.moduleReplacingConnectors()) + .module(b -> JerseyModule.extend(b).addResource(TokenApi.class)) + .createRuntime(); + + @BQApp + final BQRuntime app = Bootique.app("-c", "classpath:io/bootique/shiro/web/oidconnect/oidconnect.yml", "-s") + .module(jetty.moduleReplacingConnectors()) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.tokenUrl", serverJetty.getUrl() + "/auth")) + .module(b -> JerseyModule.extend(b).addResource(RedirectApi.class)) + .autoLoadModules() + .createRuntime(); + + @Test + public void testWithoutRequiredParameters() { + Response r = jetty.getTarget().path("bq-shiro-oauth-callback").request().get(); + JettyTester.assertBadRequest(r).assertContent("Parameters \"code\" and \"state\" are required"); + } + + @Test + public void testWithoutRequiredCodeParameter() { + Response r = jetty.getTarget().path("bq-shiro-oauth-callback").queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz").request().get(); + JettyTester.assertBadRequest(r).assertContent("Parameter \"code\" is required"); + } + + @Test + public void testWithoutRequiredStateParameter() { + Response r = jetty.getTarget().path("bq-shiro-oauth-callback").queryParam(OidConnect.CODE_PARAMETER_NAME, "123").request().get(); + JettyTester.assertBadRequest(r).assertContent("Parameter \"state\" is required"); + } + + @Test + public void testValidWithoutRedirectUrl() { + Response r = jetty.getTarget().path("bq-shiro-oauth-callback") + .queryParam(OidConnect.CODE_PARAMETER_NAME, "000") + .queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz") + .request() + .get(); + JettyTester.assertOk(r); + Map cookies = r.getCookies(); + assertNotNull(cookies); + NewCookie tokenCookie = cookies.get("bq-shiro-oid"); + assertNotNull(tokenCookie); + assertEquals("123", tokenCookie.getValue()); + } + + @Test + public void testValidWithOriginalUrl() { + Response r = jetty.getTarget().path("bq-shiro-oauth-callback") + .queryParam(OidConnect.CODE_PARAMETER_NAME, "000") + .queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz") + .queryParam(OidConnect.ORIGINAL_URI_PARAMETER_NAME, Base64.getEncoder().encodeToString(URLEncoder.encode("/public", StandardCharsets.UTF_8).getBytes())) + .request() + .get(); + JettyTester.assertOk(r).assertContent("public"); + Map cookies = r.getCookies(); + assertNotNull(cookies); + NewCookie tokenCookie = cookies.get("bq-shiro-oid"); + assertNotNull(tokenCookie); + assertEquals("123", tokenCookie.getValue()); + } + + @Path("/auth") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public static class TokenApi { + + @POST + public String auth() { + return "{\"access_token\":\"123\"}"; + } + + } + + @Path("/") + public static class RedirectApi { + + @GET + @Path("public") + public Response getPublic(@Context HttpServletRequest request) { + return response(request, "public"); + } + + @GET + @Path("private") + public Response getPrivate(@Context HttpServletRequest request) { + return response(request, "private"); + } + + private Response response(HttpServletRequest request, String entity) { + List oidTokenCookie = Arrays.stream(request.getCookies()).map(c -> new NewCookie.Builder(c.getName()).value(c.getValue()).build()).toList(); + Response.ResponseBuilder r = Response.ok(entity); + oidTokenCookie.forEach(r::cookie); + return r.build(); + } + } +} diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectBaseTest.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectBaseTest.java new file mode 100644 index 0000000..7466f66 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectBaseTest.java @@ -0,0 +1,35 @@ +package io.bootique.shiro.web.oidconnect; + +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Map; + +public class OidConnectBaseTest { + + public static String token(Map rolesClaim) throws Exception { + String key = Files.readString(Paths.get(ClassLoader.getSystemResource("io/bootique/shiro/web/oidconnect/jwks-private-key.pem").toURI())); + String privateKeyPEM = key + .replace("-----BEGIN PRIVATE KEY-----", "") + .replaceAll(System.lineSeparator(), "") + .replace("-----END PRIVATE KEY-----", ""); + + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + PrivateKey privateKey = keyFactory.generatePrivate(keySpec); + JwtBuilder builder = Jwts.builder() + .header().add("kid", "xGpTsw0DJs0vbe5CEcKMl5oZc7nKzAC9sF7kx1nQu1I") + .and() + .claims(rolesClaim).signWith(SignatureAlgorithm.RS256, privateKey); + return builder.compact(); + } +} diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java new file mode 100644 index 0000000..d13a4a6 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java @@ -0,0 +1,113 @@ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.BQCoreModule; +import io.bootique.BQRuntime; +import io.bootique.Bootique; +import io.bootique.jersey.JerseyModule; +import io.bootique.jetty.JettyModule; +import io.bootique.jetty.junit5.JettyTester; +import io.bootique.junit5.BQApp; +import io.bootique.junit5.BQTest; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +@BQTest +public class OidConnectFilterIT extends OidConnectBaseTest { + + private final JettyTester jetty = JettyTester.create(); + + private static final JettyTester serverJetty = JettyTester.create(); + + + @BQApp + static final BQRuntime tokenServerApp = Bootique.app("-s") + .module(JettyModule.class) + .module(JerseyModule.class) + .module(serverJetty.moduleReplacingConnectors()) + .module(b -> JerseyModule.extend(b).addResource(TokenApi.class)) + .createRuntime(); + + @BQApp + final BQRuntime app = Bootique.app("-c", "classpath:io/bootique/shiro/web/oidconnect/oidconnect.yml", "-s") + .module(jetty.moduleReplacingConnectors()) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.tokenUrl", serverJetty.getUrl() + "/auth")) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.oidpUrl", serverJetty.getUrl() + "/auth")) + .module(b -> JerseyModule.extend(b).addResource(TestApi.class)) + .autoLoadModules() + .createRuntime(); + + @Path("/auth") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public static class TokenApi { + + @POST + public Response authToken() { + try { + Map map = Map.of("roles", List.of("role1")); + String authToken = token(map); + return Response.ok("{\"access_token\":\"" + authToken + "\"}").build(); + } catch (Exception e) { + return Response.serverError().entity("Unable to generate auth token: " + e.getMessage()).build(); + } + } + + @GET + public Response authCode(@Context UriInfo uriInfo) { + final String callbackUrl = uriInfo.getQueryParameters().getFirst("redirect_uri") + "&code=123&state=xyz"; + return Response.status(Response.Status.FOUND).header("Location", callbackUrl).build(); + } + } + + @Path("/") + public static class TestApi { + + @GET + @Path("private") + public String getPrivate() { + return "private"; + } + } + + @Test + public void testValidWithoutCookie() { + Response r = jetty.getTarget() + .path("/private") + .queryParam("aaa", "1").queryParam("bbb", 2) + .request() + .get(); + JettyTester.assertOk(r); + } + + @Test + public void testValidWithCookie() { + try { + Map map = Map.of("roles", List.of("role1")); + String authToken = token(map); + Response r = jetty.getTarget() + .path("/private") + .queryParam("aaa", "1").queryParam("bbb", 2) + .request() + .cookie(new NewCookie.Builder("bq-shiro-oid").value(authToken).build()) + .get(); + JettyTester.assertOk(r); + } catch (Exception e) { + Assertions.fail(e); + } + } + + @Test + public void testInvalidGrantWithoutCookie() { + Response r = jetty.getTarget() + .path("/private") + .queryParam("aaa", "1").queryParam("bbb", 2) + .request() + .get(); + JettyTester.assertOk(r); + } +} diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java new file mode 100644 index 0000000..c7ab59b --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java @@ -0,0 +1,88 @@ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.BQCoreModule; +import io.bootique.BQRuntime; +import io.bootique.Bootique; +import io.bootique.jersey.JerseyModule; +import io.bootique.jetty.JettyModule; +import io.bootique.jetty.junit5.JettyTester; +import io.bootique.junit5.BQApp; +import io.bootique.junit5.BQTest; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.junit.jupiter.api.Test; + +@BQTest +public class OidConnectFilterInvalidGrantIT extends OidConnectBaseTest { + + private final JettyTester jetty = JettyTester.create(); + + private static final JettyTester serverJetty = JettyTester.create(); + + + @BQApp + static final BQRuntime tokenServerApp = Bootique.app("-s") + .module(JettyModule.class) + .module(JerseyModule.class) + .module(serverJetty.moduleReplacingConnectors()) + .module(b -> JerseyModule.extend(b).addResource(TokenApi.class)) + .createRuntime(); + + @BQApp + final BQRuntime app = Bootique.app("-c", "classpath:io/bootique/shiro/web/oidconnect/oidconnect.yml", "-s") + .module(jetty.moduleReplacingConnectors()) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.tokenUrl", serverJetty.getUrl() + "/auth")) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.oidpUrl", serverJetty.getUrl() + "/auth")) + .module(b -> JerseyModule.extend(b).addResource(TestApi.class)) + .autoLoadModules() + .createRuntime(); + + @Path("/auth") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public static class TokenApi { + + @POST + public Response authToken() { + return Response.status(Response.Status.BAD_REQUEST).entity("{\"error\":\"invalid_grant\"}").build(); + } + + @GET + public Response authCode(@Context UriInfo uriInfo) { + String state = uriInfo.getQueryParameters().getFirst(OidConnect.STATE_PARAMETER_NAME); + if (state != null) { + if (Response.Status.OK.getReasonPhrase().equals(state)) { + final String callbackUrl = uriInfo.getQueryParameters().getFirst("redirect_uri") + "&code=123&state=xyz"; + return Response.status(Response.Status.FOUND).header("Location", callbackUrl).build(); + } else if (OidConnect.INVALID_GRANT_ERROR_CODE.equals(state)) { + return Response.ok(state).build(); + } + } + return Response.status(Response.Status.BAD_REQUEST).entity("State parameter does not exist").build(); + } + } + + + @Path("/") + public static class TestApi { + + @GET + @Path("private") + public String getPrivate() { + return "private"; + } + } + + @Test + public void testInvalidGrant() { + Response r = jetty.getTarget() + .path("/private") + .queryParam("aaa", "1").queryParam("bbb", 2) + .request() + .get(); + JettyTester.assertOk(r).assertContent(OidConnect.INVALID_GRANT_ERROR_CODE); + } +} diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterUnauthorizedIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterUnauthorizedIT.java new file mode 100644 index 0000000..deef8fe --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterUnauthorizedIT.java @@ -0,0 +1,81 @@ +package io.bootique.shiro.web.oidconnect; + +import io.bootique.BQCoreModule; +import io.bootique.BQRuntime; +import io.bootique.Bootique; +import io.bootique.jersey.JerseyModule; +import io.bootique.jetty.JettyModule; +import io.bootique.jetty.junit5.JettyTester; +import io.bootique.junit5.BQApp; +import io.bootique.junit5.BQTest; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +@BQTest +public class OidConnectFilterUnauthorizedIT extends OidConnectBaseTest { + + private final JettyTester jetty = JettyTester.create(); + + private static final JettyTester serverJetty = JettyTester.create(); + + + @BQApp + static final BQRuntime tokenServerApp = Bootique.app("-s") + .module(JettyModule.class) + .module(JerseyModule.class) + .module(serverJetty.moduleReplacingConnectors()) + .module(b -> JerseyModule.extend(b).addResource(TokenApi.class)) + .createRuntime(); + + @BQApp + final BQRuntime app = Bootique.app("-c", "classpath:io/bootique/shiro/web/oidconnect/oidconnect.yml", "-s") + .module(jetty.moduleReplacingConnectors()) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.tokenUrl", serverJetty.getUrl() + "/auth")) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.oidpUrl", serverJetty.getUrl() + "/auth")) + .module(b -> JerseyModule.extend(b).addResource(TestApi.class)) + .autoLoadModules() + .createRuntime(); + + @Path("/auth") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public static class TokenApi { + + @POST + public Response authToken() { + return Response.status(Response.Status.BAD_REQUEST).entity("{\"error\":\"access_denied\"}").build(); + } + + @GET + public Response authCode(@Context UriInfo uriInfo) { + final String callbackUrl = uriInfo.getQueryParameters().getFirst("redirect_uri") + "&code=123&state=xyz"; + return Response.status(Response.Status.FOUND).header("Location", callbackUrl).build(); + } + } + + + @Path("/") + public static class TestApi { + + @GET + @Path("private") + public String getPrivate() { + return "private"; + } + } + + @Test + public void testAccessDenied() { + Response r = jetty.getTarget() + .path("/private") + .queryParam("aaa", "1").queryParam("bbb", 2) + .request() + .get(); + JettyTester.assertUnauthorized(r); + } +} diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/TokenServer.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/TokenServer.java new file mode 100644 index 0000000..80db870 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/TokenServer.java @@ -0,0 +1,4 @@ +package io.bootique.shiro.web.oidconnect; + +public class TokenServer { +} diff --git a/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks-private-key.pem b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks-private-key.pem new file mode 100644 index 0000000..bc2e04c --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCTUjjZH8RghBTv +8IDnvhKXh5d0c89uxcvpWy6/0f2l9rPmKVHeKQRS7hxHzUpotwAvLJJDghZnHJVt +wppaOxtMWNwycLwuDY5APvELhg5soedlxrISsrevW8cye/L7AURYpGfHe6Zmy43p +rzngNieMFC8ykJEPSTwaNkdks07Fl8LLPbjQodQOLKIBN2RUnRw3Bqdlspqgx+MP +McU8IGJwAeu5+lwEsGc8FwfHFMm9LSJCxrp51XrYpdTafE+PoNe4cDDbjv1TilqL +3oPZl3B5gBJ7ApS/QuYGeX47xLqBHn6JdQtqw6+/MHIW8rAVm7q/cAOMhwm9AdYN +zZCq7njfAgMBAAECggEABUFXHCOzy8+aYZlzjRDCNVvrDVvo5+YwE8x4D/QBN4Tj +6PogsgCbkgvCwCr0vw8xd1KrNbOecfeFOxZIjvRx0QOQeKTTNFSxIN3ol6/PXupY +SKX8XzS3NGRdPZH0r9CH7B49Qifz3nyKEkPObY7w/TW7yJ17QeTWZdxO0qr0zJFD +TEKqMMWTgfwgfOCwaKQIE/L/cwtmR9T/m8CoSsUjG3E6ZIKQKDEYMN5OcC6rjcvb +QUHe3RImuMGWcdIGAtBD+BRIy/qPXtK+exDGK5mBZnedc84sQ8Wq0c9+wgpwBksE +y0/rGUW+KXquc3wVgXCY4Y+quwvC8IRxqq0LtpXBsQKBgQDMUUsFVD6I1vo4cg+J +Gv6KI61KmJWWps62prM/uJzh2gDxZXQ4ibgOSqk1LgeBoqBCeGYTnZ9OhYGTFGsZ +PE8pcrKFaIkzNYvku/Jwyeh9Kb7aiO4nfTt5aStb8wXajIRwnXfl1mqvf6U52E4C +OPpMjnpl89yqwibhsY+geq76UQKBgQC4lhgsv9AEna1VIuknF8VwQm1W9NfPexoH +6olbgkx0NBJCy48GzJO+xEu3Ic80O4nuJCgGhH99bAULeGDW5KLKppY+clhDCiSk +vKPU0pRlXKyotL6M6QwDa8HRArvD3GWXxKwWdyVb83TQ0a8Qk4aohVOJjV6KrXe0 +0S+tsAFELwKBgQC6mBN3jnR97Dcgjap6gFiuN97vHWKf7z8huCRDsYo1CS+LRihZ +6gxZoP3fP2ZDkg3iJqqyh2USBQNNG3yj01xIciNviwSh6+kSwEKtlvfoNtPCKQO2 +tLw4KUAb/Vn/Og1J+8Wf9a4BEQYISe8UQIz2lbham9ePazivLcYJvYFHwQKBgBFd +3cxoB5RHmYVHEZSiAet79HmX864LsPlJsb6wVa0hMQ6jxEMpgEUUhuMmBS6u411K +fZGPacdNIHRh0Qqm3EIgxkX90BwOmj/9l5Rwc5HN1FjTGJJ9Yqn3u3aEwVG+LLjI +wkAi2Zr7HuR+te/jUWoNkTyB1oFJrNeQTuISiyv1AoGAdq9xqAYI3x+fuSIcJENA +DeeecDeeQF8Js73ixMOJZTUVrsONoPKlDEpFcMcd0PSVX6wnW8tRT5kOisnF1AKY +bFti6RS7AguNM76SL2/X9jk1yh9SWeaPVYdpZwDQhILJhm02MByxXlSQCEcqOtA5 +QR/7GvOe6mpkZDyN17fUFS8= +-----END PRIVATE KEY----- diff --git a/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks.json b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks.json new file mode 100644 index 0000000..9322727 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/jwks.json @@ -0,0 +1,12 @@ +{ + "keys":[ + {"alg":"RS256", + "e":"AQAB", + "key_ops":null, + "kid":"xGpTsw0DJs0vbe5CEcKMl5oZc7nKzAC9sF7kx1nQu1I", + "kty":"RSA", + "n":"k1I42R_EYIQU7_CA574Sl4eXdHPPbsXL6Vsuv9H9pfaz5ilR3ikEUu4cR81KaLcALyySQ4IWZxyVbcKaWjsbTFjcMnC8Lg2OQD7xC4YObKHnZcayErK3r1vHMnvy-wFEWKRnx3umZsuN6a854DYnjBQvMpCRD0k8GjZHZLNOxZfCyz240KHUDiyiATdkVJ0cNwanZbKaoMfjDzHFPCBicAHrufpcBLBnPBcHxxTJvS0iQsa6edV62KXU2nxPj6DXuHAw2479U4pai96D2ZdweYASewKUv0LmBnl-O8S6gR5-iXULasOvvzByFvKwFZu6v3ADjIcJvQHWDc2Qqu543w", + "use":"sig" + } + ] +} \ No newline at end of file diff --git a/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/oidconnect.yml b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/oidconnect.yml new file mode 100644 index 0000000..344a832 --- /dev/null +++ b/bootique-shiro-web-oidconnect/src/test/resources/io/bootique/shiro/web/oidconnect/oidconnect.yml @@ -0,0 +1,28 @@ +# Licensed to ObjectStyle LLC under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ObjectStyle LLC licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +shiroweb: + urls: + "/private" : "jwtBearerOidConnect, roles[role1]" + +shirowebjwt: + jwkLocation: "classpath:io/bootique/shiro/web/oidconnect/jwks.json" + +shiroweboidconnect: + oidpUrl: "https://www.example.org/" + clientId: "test-client" + clientSecret: "test-password" + + + diff --git a/pom.xml b/pom.xml index 796f2f4..3024296 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,7 @@ bootique-shiro-web bootique-shiro-web-mdc bootique-shiro-web-jwt + bootique-shiro-web-oidconnect From 054120583bd18b7920c93ea61fe4cb2839a625cd Mon Sep 17 00:00:00 2001 From: Vadim Vertinskiy Date: Mon, 6 Oct 2025 15:17:44 +0300 Subject: [PATCH 2/3] #46: Implementation of bootique-shiro-web-oidconnect --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3054ecf..99918ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ target *.iml .idea derby.log -*46 From 2dc0f879654efeb6056d102b7c019fda7fb1c80d Mon Sep 17 00:00:00 2001 From: Vadim Vertinskiy Date: Thu, 9 Oct 2025 21:27:56 +0300 Subject: [PATCH 3/3] #46: Refactoring according to review comments; state parameter is not used --- .../oidconnect/JwtOpenIdCallbackHandler.java | 66 ++++--------------- .../shiro/web/oidconnect/OidConnectUtils.java | 40 ++--------- .../oidconnect/ShiroWebOidConnectModule.java | 10 ++- .../ShiroWebOidConnectModuleFactory.java | 30 ++++++--- .../JwtOpenIdConnectCallbackHandlerIT.java | 18 +---- .../web/oidconnect/OidConnectFilterIT.java | 1 + .../OidConnectFilterInvalidGrantIT.java | 17 +++-- 7 files changed, 59 insertions(+), 123 deletions(-) diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java index 03b2958..b41f33d 100644 --- a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/JwtOpenIdCallbackHandler.java @@ -1,13 +1,10 @@ package io.bootique.shiro.web.oidconnect; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletRequest; -import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; -import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.*; @@ -22,10 +19,8 @@ import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -@Path("/bq-shiro-oauth-callback") +@Path("_this_is_a_placeholder_that_will_be_replaced_dynamically_") public class JwtOpenIdCallbackHandler implements OidConnect { private static final Logger LOGGER = LoggerFactory.getLogger(JwtOpenIdCallbackHandler.class); @@ -73,18 +68,14 @@ private Form form(String code) { } @GET - public Response callback(@Context UriInfo uriInfo) { - // 1. Read query parameters - MultivaluedMap params = uriInfo.getQueryParameters(); - String code = params.getFirst(CODE_PARAMETER_NAME); - String originalUri = params.getFirst(ORIGINAL_URI_PARAMETER_NAME); - String state = params.getFirst(STATE_PARAMETER_NAME); - // 2. Validate parameters - ErrorHandler errorHandler = validateRequiredParameters(code, state); - if (errorHandler.hasError()) { - return Response.status(Response.Status.BAD_REQUEST).entity(errorHandler.get()).build(); + public Response callback(@Context UriInfo uriInfo, + @QueryParam(CODE_PARAMETER_NAME) String code, + @QueryParam(ORIGINAL_URI_PARAMETER_NAME) String originalUri) { + // 1. Validate code parameter + if (code == null || code.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity("Parameter \"" + CODE_PARAMETER_NAME + "\" is required").build(); } - // 3. Exchange auth code to token on JWT server + // 2. Exchange auth code to token on JWT server Response tokenResponse = exchange(code); try { if (tokenResponse.getStatus() == HttpStatus.OK_200) { @@ -106,7 +97,7 @@ public Response callback(@Context UriInfo uriInfo) { if (error != null && error.isTextual()) { String errorCode = error.asText(); if (INVALID_GRANT_ERROR_CODE.equals(errorCode)) { - String oidpUrl = this.oidpUrl + "?" + OidConnectUtils.getOidpParametersString(uriInfo.getBaseUri().toString(), originalUri, clientId, callbackUri, true); + String oidpUrl = this.oidpUrl + "?" + getOidpParametersString(uriInfo.getBaseUri().toString(), originalUri, clientId, callbackUri); LOGGER.warn("Auth server returns error code " + INVALID_GRANT_ERROR_CODE + ". Redirection to oidp URL " + oidpUrl); return Response.status(Response.Status.FOUND).header(LOCATION_HEADER_NAME, oidpUrl).build(); } else { @@ -141,20 +132,6 @@ private WebTarget prepareOriginalTarget(URI baseUri, String encodedOriginalUri) return redirectTarget; } - private ErrorHandler validateRequiredParameters(String code, String state) { - ErrorHandler errorHandler = new ErrorHandler(); - boolean hasCode = code != null && !code.isEmpty(); - boolean hasState = state != null && !state.isEmpty(); - if (!hasCode && !hasState) { - errorHandler.append("Parameters \"code\" and \"state\" are required"); - } else if (!hasCode) { - errorHandler.append("Parameter \"code\" is required"); - } else if (!hasState) { - errorHandler.append("Parameter \"state\" is required"); - } - return errorHandler; - } - private Response exchange(String code) { Entity postForm = Entity.form(form(code)); return tokenTarget @@ -162,24 +139,9 @@ private Response exchange(String code) { .post(postForm); } - private static class ErrorHandler { - - private String error; - - void append(String error) { - if (this.error == null || this.error.isEmpty()) { - this.error = error; - } else { - this.error += "\n" + error; - } - } - - String get() { - return this.error; - } - - boolean hasError() { - return this.error != null && !this.error.isEmpty(); - } + static String getOidpParametersString(String baseUri, String originalUri, String clientId, String callbackUri) { + return OidConnect.RESPONSE_TYPE_PARAMETER_NAME + "=" + OidConnect.CODE_PARAMETER_NAME + + "&" + OidConnect.CLIENT_ID_PARAMETER_NAME + "=" + clientId + + "&" + OidConnect.REDIRECT_URI_PARAMETER_NAME + "=" + OidConnectUtils.getCallbackUri(baseUri, originalUri, callbackUri); } } diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java index e6a5c5f..ba1b206 100644 --- a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/OidConnectUtils.java @@ -2,43 +2,13 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.http.HttpServletRequest; -import jakarta.ws.rs.core.Response; import org.apache.shiro.web.util.WebUtils; -import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.*; -public class OidConnectUtils { - - static String getOidpParametersString(String baseUri, String originalUri, String clientId, String callbackUri, boolean invalidGrant) { - StringBuilder params = new StringBuilder() - .append(OidConnect.RESPONSE_TYPE_PARAMETER_NAME).append("=").append(OidConnect.CODE_PARAMETER_NAME) - .append("&").append(OidConnect.CLIENT_ID_PARAMETER_NAME).append("=").append(clientId) - .append("&").append(OidConnect.REDIRECT_URI_PARAMETER_NAME).append("=").append(getCallbackUri(baseUri, originalUri, callbackUri)) - .append("&").append(OidConnect.STATE_PARAMETER_NAME).append("=").append(getState(invalidGrant)); - return params.toString(); - } - - static Map getOidpParametersMap(String baseUri, String originalUri, String clientId, String callbackUri) { - return new HashMap<>() { - { - put(OidConnect.RESPONSE_TYPE_PARAMETER_NAME, OidConnect.CODE_PARAMETER_NAME); - put(OidConnect.CLIENT_ID_PARAMETER_NAME, clientId); - put(OidConnect.REDIRECT_URI_PARAMETER_NAME, getCallbackUri(baseUri, originalUri, callbackUri)); - put(OidConnect.STATE_PARAMETER_NAME, getState(false)); - } - }; - } - - private static String getState(boolean invalidGrant) { - // TODO: How do we generate it - if (invalidGrant) { - return OidConnect.INVALID_GRANT_ERROR_CODE; - } - return Response.Status.OK.getReasonPhrase(); - } +class OidConnectUtils { static Map getOidpParametersMap(ServletRequest servletRequest, String clientId, String callbackUri) { return new HashMap<>() { @@ -46,12 +16,12 @@ static Map getOidpParametersMap(ServletRequest servletRequest, S put(OidConnect.RESPONSE_TYPE_PARAMETER_NAME, OidConnect.CODE_PARAMETER_NAME); put(OidConnect.CLIENT_ID_PARAMETER_NAME, clientId); put(OidConnect.REDIRECT_URI_PARAMETER_NAME, getCallbackUri(servletRequest, callbackUri)); - put(OidConnect.STATE_PARAMETER_NAME, getState(false)); } }; } static String getCallbackUri(String baseUri, String originalUri, String callbackUri) { + callbackUri = resolveCallbackUri(callbackUri); if (originalUri != null && !originalUri.isEmpty()) { return baseUri + callbackUri + "?" + OidConnect.ORIGINAL_URI_PARAMETER_NAME + "=" + URLEncoder.encode( new String(Base64.getEncoder().encode(originalUri.getBytes())), StandardCharsets.UTF_8); @@ -79,7 +49,11 @@ static String getCallbackUri(ServletRequest request, String callbackUri) { if (originalUri.isEmpty()) { originalUri.append(servletRequest.getRequestURI()); } - return baseUri + callbackUri + "?" + OidConnect.ORIGINAL_URI_PARAMETER_NAME + "=" + URLEncoder.encode( + return baseUri + resolveCallbackUri(callbackUri) + "?" + OidConnect.ORIGINAL_URI_PARAMETER_NAME + "=" + URLEncoder.encode( new String(Base64.getEncoder().encode(originalUri.toString().getBytes())), StandardCharsets.UTF_8); } + + private static String resolveCallbackUri(String callbackUri) { + return callbackUri.startsWith("/") ? callbackUri : "/" + callbackUri; + } } diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java index 3f3c6d6..bf76b15 100644 --- a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModule.java @@ -23,9 +23,12 @@ import io.bootique.ModuleCrate; import io.bootique.config.ConfigurationFactory; import io.bootique.di.Binder; +import io.bootique.di.Key; import io.bootique.di.Provides; +import io.bootique.di.TypeLiteral; import io.bootique.jackson.JacksonService; import io.bootique.jersey.JerseyModule; +import io.bootique.jersey.MappedResource; import io.bootique.shiro.web.ShiroWebModule; import io.bootique.shiro.web.jwt.ShiroWebJwtModule; import io.bootique.shiro.web.jwt.ShiroWebJwtModuleFactory; @@ -52,7 +55,8 @@ public ModuleCrate crate() { @Override public void configure(Binder binder) { ShiroWebModule.extend(binder).setFilter(OID_CONNECT_BEARER_AUTHENTICATION_FILTER_NAME, OidConnectFilter.class); - JerseyModule.extend(binder).addResource(JwtOpenIdCallbackHandler.class); + JerseyModule.extend(binder).addMappedResource(new TypeLiteral>(){}); +// JerseyModule.extend(binder).addResource(JwtOpenIdCallbackHandler.class); } @Provides @@ -65,8 +69,8 @@ public OidConnectFilter provideOidConnectFilter(ConfigurationFactory configFacto @Provides @Singleton - public JwtOpenIdCallbackHandler provideOpenIdCallbackHandler(ConfigurationFactory configFactory, - JacksonService jacksonService) { + public MappedResource provideOpenIdCallbackHandler(ConfigurationFactory configFactory, + JacksonService jacksonService) { String audience = configFactory.config(ShiroWebJwtModuleFactory.class, ShiroWebJwtModule.CONFIG_PREFIX).provideAudience(); return configFactory.config(ShiroWebOidConnectModuleFactory.class, CONFIG_PREFIX) .createJwtOpenIdCallbackHandler(jacksonService, audience); diff --git a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java index 9d6fd0b..9d86869 100644 --- a/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java +++ b/bootique-shiro-web-oidconnect/src/main/java/io/bootique/shiro/web/oidconnect/ShiroWebOidConnectModuleFactory.java @@ -21,6 +21,7 @@ import io.bootique.annotation.BQConfig; import io.bootique.annotation.BQConfigProperty; import io.bootique.jackson.JacksonService; +import io.bootique.jersey.MappedResource; import io.bootique.resource.ResourceFactory; import io.bootique.shiro.web.jwt.jjwt.JwtParserMaker; import io.bootique.shiro.web.jwt.authz.AuthzReaderFactory; @@ -47,7 +48,7 @@ public class ShiroWebOidConnectModuleFactory { private String clientId; private String clientSecret; private String tokenCookie; - private String callbackUrl; + private String callbackUri; @BQConfigProperty("OpenId Connect Login Url") @@ -75,9 +76,9 @@ public void setTokenCookie(String tokenCookie) { this.tokenCookie = tokenCookie; } - @BQConfigProperty("Callback Url") - public void setCallbackUrl(String callbackUrl) { - this.callbackUrl = callbackUrl; + @BQConfigProperty("Callback Uri") + public void setCallbackUri(String callbackUri) { + this.callbackUri = callbackUri; } private String getOidpUrl() { @@ -115,18 +116,27 @@ private String getClientSecret() { return this.clientSecret; } - private String getCallbackUrl() { - if (this.callbackUrl == null || callbackUrl.isEmpty()) { + private String getCallbackUri() { + if (this.callbackUri == null || callbackUri.isEmpty()) { return DEFAULT_CALLBACK_URL; } - return this.callbackUrl; + return this.callbackUri; } public OidConnectFilter createFilter(Provider tokenParser, String audience) { - return new OidConnectFilter(tokenParser, audience, getOidpUrl(), getTokenCookie(), getClientId(), getCallbackUrl()); + return new OidConnectFilter(tokenParser, audience, getOidpUrl(), getTokenCookie(), getClientId(), getCallbackUri()); } - public JwtOpenIdCallbackHandler createJwtOpenIdCallbackHandler(JacksonService jacksonService, String audience) { - return new JwtOpenIdCallbackHandler(jacksonService.newObjectMapper(), getTokenCookie(), getTokenUrl(), getClientId(), getClientSecret(), audience, getOidpUrl(), getCallbackUrl()); + public MappedResource createJwtOpenIdCallbackHandler(JacksonService jacksonService, String audience) { + return new MappedResource<>( + new JwtOpenIdCallbackHandler( + jacksonService.newObjectMapper(), + getTokenCookie(), + getTokenUrl(), + getClientId(), + getClientSecret(), + audience, + getOidpUrl(), + getCallbackUri()), getCallbackUri()); } } diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java index a97daad..d041dcd 100644 --- a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/JwtOpenIdConnectCallbackHandlerIT.java @@ -49,29 +49,16 @@ public class JwtOpenIdConnectCallbackHandlerIT extends OidConnectBaseTest { .autoLoadModules() .createRuntime(); - @Test - public void testWithoutRequiredParameters() { - Response r = jetty.getTarget().path("bq-shiro-oauth-callback").request().get(); - JettyTester.assertBadRequest(r).assertContent("Parameters \"code\" and \"state\" are required"); - } - @Test public void testWithoutRequiredCodeParameter() { - Response r = jetty.getTarget().path("bq-shiro-oauth-callback").queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz").request().get(); + Response r = jetty.getTarget().path("bq-shiro-oauth-callback").request().get(); JettyTester.assertBadRequest(r).assertContent("Parameter \"code\" is required"); } @Test - public void testWithoutRequiredStateParameter() { - Response r = jetty.getTarget().path("bq-shiro-oauth-callback").queryParam(OidConnect.CODE_PARAMETER_NAME, "123").request().get(); - JettyTester.assertBadRequest(r).assertContent("Parameter \"state\" is required"); - } - - @Test - public void testValidWithoutRedirectUrl() { + public void testValidWithoutOriginalUrl() { Response r = jetty.getTarget().path("bq-shiro-oauth-callback") .queryParam(OidConnect.CODE_PARAMETER_NAME, "000") - .queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz") .request() .get(); JettyTester.assertOk(r); @@ -86,7 +73,6 @@ public void testValidWithoutRedirectUrl() { public void testValidWithOriginalUrl() { Response r = jetty.getTarget().path("bq-shiro-oauth-callback") .queryParam(OidConnect.CODE_PARAMETER_NAME, "000") - .queryParam(OidConnect.STATE_PARAMETER_NAME, "xyz") .queryParam(OidConnect.ORIGINAL_URI_PARAMETER_NAME, Base64.getEncoder().encodeToString(URLEncoder.encode("/public", StandardCharsets.UTF_8).getBytes())) .request() .get(); diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java index d13a4a6..2e7e04d 100644 --- a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterIT.java @@ -37,6 +37,7 @@ public class OidConnectFilterIT extends OidConnectBaseTest { .module(jetty.moduleReplacingConnectors()) .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.tokenUrl", serverJetty.getUrl() + "/auth")) .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.oidpUrl", serverJetty.getUrl() + "/auth")) + .module(b -> BQCoreModule.extend(b).setProperty("bq.shiroweboidconnect.callbackUri", "custom-oauth-callback")) .module(b -> JerseyModule.extend(b).addResource(TestApi.class)) .autoLoadModules() .createRuntime(); diff --git a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java index c7ab59b..61fbbbf 100644 --- a/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java +++ b/bootique-shiro-web-oidconnect/src/test/java/io/bootique/shiro/web/oidconnect/OidConnectFilterInvalidGrantIT.java @@ -45,6 +45,8 @@ public class OidConnectFilterInvalidGrantIT extends OidConnectBaseTest { @Produces(MediaType.APPLICATION_JSON) public static class TokenApi { + private boolean authCodeAlreadyUsed = false; + @POST public Response authToken() { return Response.status(Response.Status.BAD_REQUEST).entity("{\"error\":\"invalid_grant\"}").build(); @@ -52,16 +54,13 @@ public Response authToken() { @GET public Response authCode(@Context UriInfo uriInfo) { - String state = uriInfo.getQueryParameters().getFirst(OidConnect.STATE_PARAMETER_NAME); - if (state != null) { - if (Response.Status.OK.getReasonPhrase().equals(state)) { - final String callbackUrl = uriInfo.getQueryParameters().getFirst("redirect_uri") + "&code=123&state=xyz"; - return Response.status(Response.Status.FOUND).header("Location", callbackUrl).build(); - } else if (OidConnect.INVALID_GRANT_ERROR_CODE.equals(state)) { - return Response.ok(state).build(); - } + if (!authCodeAlreadyUsed) { + final String callbackUrl = uriInfo.getQueryParameters().getFirst("redirect_uri") + "&code=123&state=xyz"; + authCodeAlreadyUsed = true; + return Response.status(Response.Status.FOUND).header("Location", callbackUrl).build(); + } else { + return Response.ok(OidConnect.INVALID_GRANT_ERROR_CODE).build(); } - return Response.status(Response.Status.BAD_REQUEST).entity("State parameter does not exist").build(); } }