OIDC-Servlets is a library of servlets and filters using the Nimbus OIDC SDK to implement an OpenID Connect relying-party.
For a few years I've been using Pac4j to secure web apps with OpenID Connect, but as requirements differ slightly between applications, I've found Pac4j's complexity to be overwhelming given my relatively simple needs. Pac4j is a very well-thought-out authentication framework that allows handling many cases, and it's a great fit for, for example but not limited to, products that can be deployed in various environments, but that comes at a cost of complexity. My needs are rather simple, so I want something simpler, and it turns out Nimbus (that powers Pac4j's OpenID Connect support) is relatively easy to use on its own, so that's what I'm doing here.
The project requires a JDK in version 21 or higher, and Docker with Docker Compose.
It fulfills the following needs:
- A public (authentication-aware, but not requiring authentication) homepage
- A private (requiring authentication) page, accessible to any registered user
- A private (requiring authentication) admin page, only accessible to administrator users
- A single API servlet that can tell users apart and handle authorizations (depending on projects this could be Jakarta RS or GraphQL for example)
- Static resources don't necessarily need authentication (more precisely, subresources –whether static or not, though most likely they are– should not redirect for authentication but rather either be served anyway or just blocked, both behaviors should be possible depending on needs)
- Authentication should work well with internal servlet forwarding, as that's how I serve the same HTML web page for various URLs for Single Page Applications (SPA) that are fully client-side rendered (CSR); more precisely, the URL to redirect to after authentication should be the originally requested URL and not the one the request has been forwarded to.
First, start a Keycloak server with an example configuration with Docker Compose:
docker compose up -d
This will start Keycloak, then configure a realm, a client, and a couple users, using keycloak-config-cli.
The server listens on http://localhost:8080/, the Keycloak administrator is kcadmin
/kcadmin
, and test users in the example
realm are admin
/admin
and user
/user
.
Then start the example application (preconfigured to integrate with that Keycloak server and configuration):
./gradlew run
This will compile the code then execute it. Hit Ctrl+C to terminate the process. The server listens on http://localhost:8000/.
Create Configuration
and AuthenticatorRedirector
objects and add them as ServletContext
attributes (the attribute names are in the CONTEXT_ATTRIBUTE_NAME
static constants of each class):
var configuration = new Configuration(/* … */);
var redirector = new AuthenticationRedirector(configuration, CALLBACK_PATH);
servletContext.setAttribute(Configuration.CONTEXT_ATTRIBUTE_NAME, configuration);
servletContext.setAttribute(AuthenticationRedirector.CONTEXT_ATTRIBUTE_NAME, redirector);
Register the CallbackServlet
to the path configured with the AuthenticationRedirector
:
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addServlet("oidc-callback", CallbackServlet.class)
.addMapping(CALLBACK_PATH);
// Using Jetty's ServletContextHandler
servletContextHandler.addServlet(CallbackServlet.class, CALLBACK_PATH);
// Using Undertow
Servlets.servlet(CallbackServlet.class).addMapping(CALLBACK_PATH);
To determine if the user is logged in, register the UserFilter
, most likely to all requests, and it should match early; this filter will set up the HttpServletRequest
for later filters and servlets to answer the getRemoteUser()
, getUserPrincipal()
, and isUserInRole(String)
methods:
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addFilter("user", UserFilter.class)
.addMappingForUrlPatterns(null, false, "/*");
The implementation of isUserInRole(String)
relies on the actual UserPrincipal
, which is derived from the ID Token and User Info. The default implementation (SimpleUserPrincipal
) always returns false
(the user has no known role). Another implementation (KeycloakUserPrincipal
) reads Keycloak realm roles from the User Info, and can be configured by passing the class' constructor to the Configuration
constructor:
var configuration = new Configuration(
providerMetadata, clientAuthentication, KeycloakUserPrincipal::new);
Custom UserPrincipal
implementations can be created for other OpenID Providers.
Now, to redirect to the OpenID Provider, register one or many authorization filters, depending on needs. The IsAuthenticatedFilter
requires an authenticated user; it's more or less equivalent to the <role-name>*</role-name>
security constraint of standard servlet security (when authentication is delegated to the servlet container). The HasRoleFilter
requires that the user has a given role, that needs to be configured with the role
init parameter, or passed to the filter constructor; it's more or less equivalent to a <role-name>
security constraint (though only supporting one role). Other needs can be fulfilled by subclassing AbstractAuthorizationFilter
. Those filters rely on the user detected by the UserFilter
, so beware of filter ordering.
For example, for an application that requires authentication everywhere:
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addFilter("authenticated-user", IsAuthenticatedFilter.class)
.addMappingForUrlPatterns(null, true, "/*");
or for an application with public and private sections:
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addFilter("authenticated-user", IsAuthenticatedFilter.class)
.addMappingForUrlPatterns(null, true, "/private/*");
To allow users on public pages to sign in, you can register the LoginServlet
, and add to those pages either a link to that servlet, or an HTML form to do a POST
request to that servlet, including the URL to return to after authentication in a return-to
query-string or form parameter (if omitted, the user will be redirected to the root of the application, same as return-to=/
):
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addServlet("login", LoginServlet.class)
.addMapping("/login");
The target page should be given as an absolute path (possibly with a query string), though a full URL would be accepted as long as it's the same origin. You can use Utils.getRequestUri(request)
to easily get the path and query string of the current request (taking into account internal servlet forwarding to return the information of the original request). Here's an example in a JSP:
<!-- Using a link -->
<a href='/login?<c:out value="${Utils.RETURN_TO_PARAMETER_NAME}" />=<c:out value="${Utils.getRequestUri(request)}" />'>Sign in</a>
<!-- Using a form -->
<form method="post" action="/login">
<input type="hidden" name='<c:out value="${Utils.RETURN_TO_PARAMETER_NAME}" />'
value='<c:out value="${Utils.getRequestUri(request)}" />'>
<button type="submit">Sign in</button>
</form>
To allow users to sign out, register the LogoutServlet
and add an HTML form to the application to do a POST
request to that servlet. By default, no post_logout_redirect_uri
is being used, so most likely the OpenID Provider will display a page to the user confirming their logout, and possibly including a link back to the application.
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addServlet("logout", LogoutServlet.class)
.addMapping("/logout");
To use a post_logout_redirect_uri
, configure its path with the post-logout-redirect-path
init parameter (or passing the value to the servlet constructor); this should be a public page, otherwise the user will directly be sent back to the OpenID Provider for signing in again, and it should be properly registered at the OpenID Provider:
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.addServlet("logout", new LogoutServlet("/"))
.addMapping("/logout");
To allow the redirection target to be dynamically chosen (e.g. to return to the public page the user signed out from):
-
register the
LogoutCallbackServlet
in addition to theLogoutServlet
:// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer) servletContext.addServlet("logout-callback", LogoutCallbackServlet.class) .addMapping(LOGOUT_CALLBACK_PATH);
-
configure the
use-logout-state
init parameter totrue
(or passtrue
as the second argument to the servlet constructor), and configure thepost-logout-redirect-path
to the path of theLogoutCallbackServlet
(which should be properly registered on the OpenID Provider):// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer) servletContext.addServlet("logout", new LogoutServlet(LOGOUT_CALLBACK_PATH, true)) .addMapping("/logout");
-
pass the target page as a
return-to
form parameter (make sure it's a public page to avoid immediately redirecting back to the OpenID Provider for authentication); the target page should be given as an absolute path (possibly with a query string), though a full URL would be accepted as long as its the same origin; you can useUtils.getRequestUri(request)
to easily get the path and query string of the current request (taking into account internal servlet forwarding to return the information of the original request). Here's an example in a JSP:<form method="post" action="/logout"> <input type="hidden" name="<c:out value="${Utils.RETURN_TO_PARAMETER_NAME}" />" value="<c:out value="${Utils.getRequestUri(request)}" />"> <button type="submit">Sign out</button> </form>
To use OpenID Connect Back-Channel Logout, you need a way to invalidate sessions based on an identifier managed by the OpenID Provider (the sid
). This can be accomplished by storing the identifiers of the logged-out sessions and then matching them whenever a request comes in to invalidate them on a case-by-case basis (this means the sessions are only effectively terminated the next time they're used, and not immediately). This is implemented here with the LoggedOutSessionStore
that will be used by the UserFilter
when one is added as a ServletContext
attribute, and the BackchannelLogoutSessionListener
will remove effectively invalidated identifiers from the LoggedOutSessionStore
. Finally, the BackchannelLogoutServlet
, whose URL has to be properly registered on the OpenID Provider, will receive the logout requests from the OpenID Provider and put the identifiers into the LoggedOutSessionStore
after validating the request. This requires that the OpenID Provider sends a sid
in the ID Token at authentication time, and in the Logout Token sent to the BackchannelLogoutServlet
(i.e. the provider metadata has "backchannel_logout_session_supported": true
, and the client registration would have "backchannel_logout_session_required": true
).
// Using the ServletContext dynamic registration (e.g. from ServletContextInitializer)
servletContext.setAttribute(
LoggedOutSessionStore.CONTEXT_ATTRIBUTE_NAME, new InMemoryLoggedOutSessionStore());
servletContext.addListener(new BackchannelLogoutSessionListener());
servletContext.addServlet("backchannel-logout", BackchannelLogoutServlet.class)
.addMapping("/backchannel-logout");