Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand All @@ -80,4 +80,16 @@ private void validateAudience(Set<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ public JwtBearerAuthenticationFilter createFilter(Provider<JwtParser> tokenParse
return new JwtBearerAuthenticationFilter(tokenParser, this.audience);
}

public String provideAudience() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I forgot that the new filter will also need audience. I suppose we can keep it here for now, but after we merge the PR, I am thinking of refactoring both shirowebjwt and shiroweboidconnect config structure, so that we don't need to share properties (likely going back to your original idea of nested configurations were properties are organized by "filter", "callback", "jwks", etc.).

Anyways, no change is needed in this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

return this.audience;
}

private AuthzReaderFactory getRoles() {
return roles != null ? roles : new JsonListAuthzReaderFactory();
}
Expand Down
103 changes: 103 additions & 0 deletions bootique-shiro-web-oidconnect/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.bootique.shiro</groupId>
<artifactId>bootique-shiro-parent</artifactId>
<version>4.0-SNAPSHOT</version>
</parent>

<artifactId>bootique-shiro-web-oidconnect</artifactId>
<packaging>jar</packaging>

<name>bootique-shiro-web-oidconnect: Bootique Shiro OpenID Connect</name>
<description>Integration of OpenID Connect to Bootique Shiro</description>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.bootique.jersey</groupId>
<artifactId>bootique-jersey</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.bootique.jetty</groupId>
<artifactId>bootique-jetty-junit5</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- Compile dependencies -->
<dependency>
<groupId>io.bootique.shiro</groupId>
<artifactId>bootique-shiro-web-jwt</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.bootique.jersey</groupId>
<artifactId>bootique-jersey</artifactId>
</dependency>
<!-- Unit test dependencies -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.bootique.jetty</groupId>
<artifactId>bootique-jetty-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<!-- Optional profile used to sign artifacts -->
<profiles>
<profile>
<id>gpg</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package io.bootique.shiro.web.oidconnect;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
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;

@Path("_this_is_a_placeholder_that_will_be_replaced_dynamically_")
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,
@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();
}
// 2. 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 + "?" + 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 {
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 Response exchange(String code) {
Entity<Form> postForm = Entity.form(form(code));
return tokenTarget
.request()
.post(postForm);
}

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);
}
}
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -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<JwtParser> 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);
}
}
}
Loading