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.
You will need Docker Compose to run the example application, or run the tests locally (e.g. to contribute).
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 JAX-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.
- Dependency-injection friendly
- OAuth tokens (access token and refresh token) obtained at authentication time should be exposed to the application and have their own lifecycle.
The primary goal is to implement the authorization code flow with the (default) query response mode and PKCE, bearer tokens, client authentication using a client secret with HTTP Basic, RP-initiated logout, and backchannel logout. Support for DPoP is also baked in.
Hooks allow customizing the authentication request for, e.g., using the form post response mode, pushed authorization requests (PAR), and/or JWT-secured authorization requests (JAR). It's also possible to authenticate the client using a client secret within the form post body, or through mTLS or JWT.
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://auth.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://app.localhost:8000/.
Add a dependency on net.ltgt.oidc:oidc-servlets;
also add dependencies on com.nimbusds:oauth2-oidc-sdk and com.nimbusds:nimbus-jose-jwt so you can keep them up-to-date independently of OIDC-Servlets.
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);Note
You can also pass those objects to the servlets' and filters' constructor if you instantiate them yourself (or through a dependency-injection framework), rather than using ServletContext attributes. The same is true for values passed as init parameters.
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). Other implementations can be used by configuring a UserPrincipalFactory as a ServletContext attribute. Another built-in implementation reads Keycloak realm roles from the User Info, and can be configured by using the KeycloakUserPrincipal.FACTORY factory:
servletContext.setAttribute(
UserPrincipalFactory.CONTEXT_ATTRIBUTE_NAME, KeycloakUserPrincipal.FACTORY);Custom implementations can also read additional data (e.g. from a database) to expose in their custom UserPrincipal. If they can't afford doing it on each request, or would just rather do it once and cache it for the duration of the session, they can implement the UserPrincipalFactory's userAuthenticated hook to retrieve that data and store it in the session whenever the CallbackServlet authenticates the user, and then pull that data out of the session to construct the custom UserPrincipal on each subsequent request. That userAuthenticated hook can also be used to synchronize user data from the OpenID Provider to a local database.
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/*");Note
If you use JAX-RS in your application, you can also use the OIDC-Servlets+RS companion library to manage authorizations on your JAX-RS resources.
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
LogoutCallbackServletin 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-stateinit parameter totrue(or passtrueas the second argument to the servlet constructor), and configure thepost-logout-redirect-pathto 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-toform 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 is implemented here with the LoggedOutSessionStore, and the BackchannelLogoutServlet, whose URL has to be properly registered on the OpenID Provider, will receive the logout requests from the OpenID Provider and pass the identifiers to 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).
There are several ways to implement LoggedOutSessionStore, depending on the actual session store implementation. One way is to track ongoing sessions and establish a mapping between the OpenID Provider sid and the session ID; this is implemented by the BackchannelLogoutSessionListener, alongside the InMemoryLoggedOutSessionStore. That mapping can then be used whenever a request comes in to invalidate its session 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 in the UserFilter.
// 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");The mapping can also be used directly when a backchannel logout request is received to immediately invalidate sessions (without waiting for the next request); this depends on the actual implementation of the session manager in the servlet container, so will have to be implemented by subclassing InMemoryLoggedOutSessionListener and implementing its doLogout method. In an embedded Jetty server, one could immediately invalidate sessions with code like:
// Using the Jetty API
contextHandler.setAttribute(
LoggedOutSessionStore.CONTEXT_ATTRIBUTE_NAME,
new InMemoryLoggedOutSessionStore() {
@Override
protected void doLogout(Set<String> sessionIds) {
for (var sessionId : sessionIds) {
var session = contextHandler.getSessionHandler().getManagedSession(sessionId);
if (session != null) {
session.invalidate();
}
}
}
});To get the access token and refresh token obtained at authentication time, add an OAuthTokensHandler implementation as a ServletContext attribute. You will then be able to access protected resources using the access token, and obtain new access tokens with the refresh token, but will be responsible for revoking the access tokens when no longer needed (e.g. when the session expires); this is all out of scope of this library though. The default behavior is to directly revoke the access token after it's been used to access the User Info endpoint.
Here's an example that stores the tokens in the session:
servletContext.setAttribute(
OAuthTokensHandler.CONTEXT_ATTRIBUTE_NAME,
new OAuthTokensHandler() {
@Override
public void tokensAcquired(AccessTokenResponse tokenResponse, HttpSession session) {
session.setAttribute(TOKENS_SESSION_ATTRIBUTE_NAME, tokenResponse);
}
});To use DPoP, register a DPoPSupport instance as a ServletContext attribute.
For end-to-end binding of the entire authentication flow, pass that same DPoPSupport instance to the AuthenticationRedirector constructor.
If the identity provider uses DPoP nonces, you can customize the way those nonces are remembered and used between requests by registering a DPoPNonceStore instance as a ServletContext attribute. This allows optimizing nonce usage compared to the default behavior based on the identity provider's own behavior.
var configuration = new Configuration(/* … */);
var dpopSupport = DPoPSupport.create(/* … */);
var redirector = new AuthenticationRedirector(configuration, CALLBACK_PATH, dpopSupport);
servletContext.setAttribute(Configuration.CONTEXT_ATTRIBUTE_NAME, configuration);
servletContext.setAttribute(AuthenticationRedirector.CONTEXT_ATTRIBUTE_NAME, redirector);
servletContext.setAttribute(DPoPSupport.CONTEXT_ATTRIBUTE_NAME, dpopSupport);
servletContext.setAttribute(
DPoPNonceStore.CONTEXT_ATTRIBUTE_NAME, new SinglerDPoPNonceStore());- OpenID Connect Core (encrypted ID Tokens aren't supported, as well as User Info responses as JWT)
- OpenID Connect Discovery used indirectly through Nimbus
OIDCProviderMetadata.resolve()to then create the OIDC-Servlet'sConfiguration - OpenID Connect RP-Initiated Logout
- OpenID Connect Back-Channel Logout
- Proof Key for Code Exchange by OAuth Public Clients (RFC 7636)
- OAuth 2.0 Token Revocation (RFC 7009)
- The OAuth 2.0 Authorization Framework: Bearer Token Usage (RFC 6750) to call the User Info Endpoint
- OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- OAuth 2.1 (draft)
- OAuth 2.0 Threat Model and Security Considerations (RFC 6819) / Best Current Practice for OAuth 2.0 Security (RFC 9700)
- OAuth 2.0 Form Post Response Mode is supported, but not used by default; note that it requires that the identity provider and the application are same site (or that the servlet session cookies be set with
SameSite=Nonewhich would be a very bad idea) - OAuth 2.0 Pushed Authorization Requests
- JWT-Secured Authorization Request (JAR)
- JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants