Implementing Custom Token-Based Auth on Liberty With Domino
Sat Apr 24 12:31:00 EDT 2021
This weekend, I decided to embark on a small personal side project: implementing an RSS sync server I can use with NetNewsWire. It's the delightful sort of side project where the stakes are low and so I feel no pressure to actually complete it (I already have what I want with iCloud-based syncing), but it's a great learning exercise.
Fair warning: this post is essentially a travelogue of not-currently-public code for an incomplete side app of mine, and not necessarily useful as a tutorial. I may make a proper example project out of these ideas one day, but for the moment I'm just excited about how smoothly this process has gone.
The Idea
NetNewsWire syncs with a number of services, and one of them is FreshRSS, a self-hosted sync tool that uses PHP backed by an RDBMS. The implementation doesn't matter, though: what matters is that that means that NNW has the ability to point at any server at an arbitrary URL implementing the same protocol.
As for the protocol itself, it turns out it's just the old Google Reader protocol. Like Rome, Reader rose, transformed the entire RSS ecosystem, and then crumbled, leaving its monuments across the landscape like scars. Many RSS sync services have stuck with that language ever since - it's a bit gangly, but it does the job fine, and it lowers the implementation toll on the clients.
So I figured I could find some adequate documentation and make a little webapp implementing it.
Authentication
My starting point (and all I've done so far) was to get authentication working. These servers mimic the (I assume antiquated) Google ClientLogin endpoint, where you POST "Email" and "Passwd" and get back a token in a weird little properties-ish format:
1 2 3 4 | POST /accounts/ClientLogin HTTP/1.1 Content-Type: application/x-www-form-urlencoded Email=ffooson&Passwd=secretpassword |
Followed by:
1 2 3 4 5 6 | HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 SID=null LSID=null Auth=somename/8e6845e089457af25303abc6f53356eb60bdb5f8 |
The format of the "Auth" token doesn't matter, I gather. I originally saw it in that "name/token" pattern, but other cases are just a token. That makes sense, since there's no need for the client to parse it - it just needs to send it back. In practice, it shouldn't have any "=" in it, since NNW parses the format expecting only one "=", but otherwise it should be up to you. Specifically, it will send it along in future requests as the Authorization
header:
1 2 | GET /reader/api/0/stream/items/ids?n=1000&output=json&s=user/-/state/com.google/starred HTTP/1.1 Authorization: GoogleLogin auth=somename/8e6845e089457af25303abc6f53356eb60bdb5f8 |
This is pretty standard stuff for any number of authentication schemes: often it'll start with "Bearer" instead of "GoogleLogin", but the idea is the same.
Implementing This
So how would one go about implementing this? Well, fortunately, the Jakarta EE spec includes a Security API that allows you to abstract the specifics of how the container authenticates a user, providing custom user identity stores and authentication mechanisms instead of or in addition to the ones provided by the container itself. This is as distinct from a container like Domino, where the HTTP stack handles authentication for all apps, and the only way to extend how that works is by writing a native library with the C-based DSAPI. Possible, but cumbersome.
Identity Store
We'll start with the identity store. Often, a container will be configured with its own concept of what the pool of users is and how they can be authenticated. On Domino, that's generally the names.nsf plus anything configured in a Directory Assistance database. On Liberty or another JEE container, that might be a static user list, an LDAP server, or any number of other options. With the Security API, you can implement your own. I've been ferrying around classes that look like this for a couple of years now:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | /* snip */ import javax.security.enterprise.credential.Credential; import javax.security.enterprise.credential.UsernamePasswordCredential; import javax.security.enterprise.identitystore.CredentialValidationResult; import javax.security.enterprise.identitystore.IdentityStore; @ApplicationScoped public class NotesDirectoryIdentityStore implements IdentityStore { @Inject AppConfig appConfig; @Override public int priority() { return 70; } @Override public Set<ValidationType> validationTypes() { return DEFAULT_VALIDATION_TYPES; } public CredentialValidationResult validate(UsernamePasswordCredential credential) { try { try(DominoClient client = DominoClientBuilder.newDominoClient().build()) { String dn = client.validateCredentials(appConfig.getAuthServer(), credential.getCaller(), credential.getPasswordAsString()); return new CredentialValidationResult(null, dn, dn, dn, getGroups(dn)); } } catch (NameNotFoundException e) { return CredentialValidationResult.NOT_VALIDATED_RESULT; } catch (AuthenticationException | AuthenticationNotSupportedException e) { return CredentialValidationResult.INVALID_RESULT; } } @Override public Set<String> getCallerGroups(CredentialValidationResult validationResult) { String dn = validationResult.getCallerDn(); return getGroups(dn); } /* snip */ } |
There's a lot going on here. To start with, the Security API goes hand-in-hand with CDI. That @ApplicationScoped
annotation on the class means that this IdentityStore
is an app-wide bean - Liberty picks up on that and registers it as a provider for authentication. The AppConfig
is another CDI bean, this one housing the Domino server I want to authenticate against if not the local runtime (handy for development).
The IdentityStore
interface definition does a little magic for identifying how to authenticate. The way it works is that the system uses objects that implement Credential
, an extremely-generic interface to represent any sort of credential. When the default implementation is called, it looks through your implementation class for any methods that can handle the specific credential class that came in. You can see above that validate(UsernamePasswordCredential credential)
isn't tagged with @Override
- that's because it's not implementing an existing method. Instead, the core validate
looks for other methods named validate
to take the incoming class. UsernamePasswordCredential
is one of the few stock ones that comes with the API and is how the container will likely ask for authentication if using e.g. HTTP Basic auth.
Here, I use some Domino API to check the username+password combination against the Domino directory and inform the caller whether the credentials match and, if so, what the user's distinguished name and group memberships are (with some implementation removed for clarity).
Token Authentication
That's all well and good, and will allow a user to log into the app with HTTP Basic authentication with a Domino username and password, but I'd also like the aforementioned GoogleLogin tokens to count as "real" users in the system.
To start doing that, I created a JAX-RS resource for the expected login URL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @Path("accounts") public class AccountsResource { @Inject TokenBean tokens; @Inject IdentityStore identityStore; @PermitAll @Path("ClientLogin") @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) public String post(@FormParam("Email") @NotEmpty String email, @FormParam("Passwd") String password) { CredentialValidationResult result = identityStore.validate(new UsernamePasswordCredential(email, password)); switch(result.getStatus()) { case VALID: Token token = tokens.createToken(result.getCallerDn()); String mangledDn = result.getCallerDn().replace('=', '_').replace('/', '_'); return MessageFormat.format("SID=null\nLSID=null\nAuth={0}\n", mangledDn + "/" + token.token()); //$NON-NLS-1$ //$NON-NLS-2$ default: // TODO find a better exception throw new RuntimeException("Invalid credentials"); } } } |
Here, I make use of the IdentityStore
implementation above to check the incoming username/password pair. Since I can @Inject
it based on just the interface, the fact that it's authenticating against Domino isn't relevant, and this class can remain blissfully unaware of the actual user directory. All it needs to know is whether the credentials are good. In any event, if they are, it returns the weird little format in the response and the RSS client can then use it in the future.
The TokenBean
class there is another custom CDI bean, and its job is to create and look up tokens in the storage NSF. The pertinent part is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @ApplicationScoped public class TokenBean { @Inject @AdminUser Database adminDatabase; public Token createToken(String userName) { Token token = new Token(UUID.randomUUID().toString().replace("-", ""), userName); //$NON-NLS-1$ //$NON-NLS-2$ adminDatabase.createDocument() .replaceItemValue("Form", "Token") //$NON-NLS-1$ //$NON-NLS-2$ .replaceItemValue("Token", token.token()) //$NON-NLS-1$ .replaceItemValue("User", token.user()) //$NON-NLS-1$ .save(); return token; } /* snip */ } |
Nothing too special there: it just creates a random token string value and saves it in a document. The token could be anything; I could have easily gone with the document's UNID, since it's basically the same sort of value.
I'll save the @Inject @AdminUser
bit for another day, since we're already far enough into the CDI weeds here. Suffice it to say, it injects a Database
object for the backing data DB for the designated admin user - basically, like opening the current DB with sessionAsSigner
in XPages. The @AdminUser
is a custom annotation in the app to convey this meaning.
Okay, so great, now we have a way for a client to log in with a username and password and get a token to then use in the future. That leaves the next step: having the app accept the token as an equivalent authentication for the user.
Intercepting the incoming request and analyzing the token is done via another Jakarta Security API interface: HttpAuthenticationMechanism
. Creating a bean of this type allows you to look at an incoming request, see if it's part of your custom authentication, and handle it any way you want. In mine, I look for the "GoogleLogin" authorization header:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @ApplicationScoped public class TokenAuthentication implements HttpAuthenticationMechanism { @Inject IdentityStore identityStore; @Override public AuthenticationStatus validateRequest(HttpServletRequest request, HttpServletResponse response, HttpMessageContext httpMessageContext) throws AuthenticationException { String authHeader = request.getHeader("Authorization"); //$NON-NLS-1$ if(StringUtil.isNotEmpty(authHeader) && authHeader.startsWith(GoogleAccountTokenHandler.AUTH_PREFIX)) { CredentialValidationResult result = identityStore.validate(new GoogleAccountTokenHeaderCredential(authHeader)); switch(result.getStatus()) { case VALID: httpMessageContext.notifyContainerAboutLogin(result); return AuthenticationStatus.SUCCESS; default: return AuthenticationStatus.SEND_FAILURE; } } return AuthenticationStatus.NOT_DONE; } } |
Here, I look for the "Authorization" header and, if it starts with "GoogleLogin auth="
, then I parse it for the token, create an instance of an app-custom GoogleAccountTokenHeaderCredential
object (implementing Credential
) and ask the app's IdentityStore
to authorize it.
Returning to the IdentityStore
implementation, that meant adding another validate
override:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @ApplicationScoped public class NotesDirectoryIdentityStore implements IdentityStore { /* snip */ public CredentialValidationResult validate(GoogleAccountTokenHeaderCredential credential) { try { try(DominoClient client = DominoClientBuilder.newDominoClient().build()) { String dn = client.validateCredentialsWithToken(appConfig.getAuthServer(), credential.headerValue()); return new CredentialValidationResult(null, dn, dn, dn, getGroups(dn)); } } catch (NameNotFoundException e) { return CredentialValidationResult.NOT_VALIDATED_RESULT; } catch (AuthenticationException | AuthenticationNotSupportedException e) { return CredentialValidationResult.INVALID_RESULT; } } } |
This one looks similar to the UsernamePasswordCredential
one above, but takes instances of my custom Credential
class - automatically picked up by the default implementation. I decided to be a little extra-fancy here: the particular Domino API in question supports custom token-based authentication to look up a distinguished name, and I made use of that here. That takes us one level deeper:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public class GoogleAccountTokenHandler implements CredentialValidationTokenHandler<String> { public static final String AUTH_PREFIX = "GoogleLogin auth="; //$NON-NLS-1$ @Override public boolean canProcess(Object token) { if(token instanceof String authHeader) { return authHeader.startsWith(AUTH_PREFIX); } return false; } @Override public String getUserDn(String token, String serverName) throws NameNotFoundException, AuthenticationException, AuthenticationNotSupportedException { String userTokenPair = token.substring(AUTH_PREFIX.length()); int slashIndex = userTokenPair.indexOf('/'); if(slashIndex >= 0) { String tokenVal = userTokenPair.substring(slashIndex+1); Token authToken = CDI.current().select(TokenBean.class).get().getToken(tokenVal) .orElseThrow(() -> new AuthenticationException(MessageFormat.format("Unable to find token \"{0}\"", token))); return authToken.user(); } throw new AuthenticationNotSupportedException("Malformed token"); } } |
This is the Domino-specific one, inspired by the Jakarta Security API. I could also have done this lookup in the previous class, but this way allows me to reuse this same custom authentication in any API use.
Anyway, this class uses another method on TokenBean
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @ApplicationScoped public class TokenBean { @Inject @AdminUser Database adminDatabase; /* snip */ public Optional<Token> getToken(String tokenValue) { return adminDatabase.openCollection("Tokens") //$NON-NLS-1$ .orElseThrow(() -> new IllegalStateException("Unable to open view \"Tokens\"")) .query() .readColumnValues() .selectByKey(tokenValue, true) .firstEntry() .map(entry -> new Token(entry.get("Token", String.class, ""), entry.get("User", String.class, ""))); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ } } |
There, it looks up the requested token in the "Tokens" view and, if present, returns a record indicating that token and the user it was created for. The latter is then returned by the above Domino-custom GoogleAccountTokenHandler
as the authoritative validated user. In turn, the JEE NotesDirectoryIdentityStore
considers the credential validation successful and returns it back to the auth mechanism. Finally, the TokenAuthentication
up there sees the successful validation and notifies the container about the user that the token mapped to.
Summary
So that turned into something of a long walk at the end there, but the result is really neat: as far as my app is concerned, the "GoogleLogin" tokens - as looked up in an NSF - are just as good as username/password authentication. Anything that calls httpServletRequest.getUserPrincipal()
will see the username from the token, and I also use this result to spawn the Domino session object for each request.
Once all these pieces are in place, none of the rest of the app has to have any knowledge of it at all. When I implement the API to return the actual RSS feed entries, I'll be able to just use the current user, knowing that it's guaranteed to be properly handled by the rest of the system beforehand.
Bonus: Java 16
This last bit isn't really related to the above, but I just want to gush a bit about newer techs. My plan is to deploy this app using my Open Liberty Runtime, which means I can use any Open Liberty and Java version I want. Java 16 came out recently, so I figured I'd give that a shot. Though I don't think Liberty is officially supported on it yet, it's worked out just fine for my needs so far.
This lets me use the features that have come into Java in the last few years, a couple of which moved from experimental/incubating into finalized forms in 16 specifically. For example, I can use records, a specialized type of Java class intended for immutable data. Token
is a perfect case for this:
1 2 | public record Token(String token, String user) { } |
That's the entirety of the class. Because it's a record, it gets a constructor with those two properties, plus accessor methods named after the properties (as used in the examples above). Neat!
Another handy new feature is pattern matching for instanceof
. This allows you to simplify the common idiom where you check if an object is a particular type, then cast it to that type afterwards to do something. With this new syntax, you can compress that into the actual test, as seen above:
1 2 3 4 5 6 7 | @Override public boolean canProcess(Object token) { if(token instanceof String authHeader) { return authHeader.startsWith(AUTH_PREFIX); } return false; } |
Using this allows me to check the incoming value's type while also immediately creating a variable to treat it as such. It's essentially the same thing you could do before, but cleaner and more explicit now. There's more of this kind of thing on the way, and I'm looking forward to the future additions eagerly.