Per-NSF-Scoped JWT Authorization With JavaSapi
Sat Jun 04 10:35:05 EDT 2022
- Poking Around With JavaSapi
- Per-NSF-Scoped JWT Authorization With JavaSapi
- WebAuthn/Passkey Login With JavaSapi
In the spirit of not leaving well enough alone, I decided the other day to tinker a bit more with JavaSapi, the DSAPI peer tucked away undocumented in Domino. While I still maintain that this is too far from supported for even me to put into production, I think it's valuable to demonstrate the sort of thing that this capability - if made official - would make easy to implement.
JWT
I've talked about JWT a bit before, and it was in a similar context: I wanted to be able to access a third-party API that used JWT to handle authorization, so I wrote a basic library that could work with LS2J. While JWT isn't inherently tied to authorization like this, it's certainly where it's found a tremendous amount of purchase.
JWT has a couple neat characteristics, and the ones that come in handy most frequently are a) that you can enumerate specific "claims" in the token to restrict what the token allows the user to do and b) if you use a symmetric signature key, you can generate legal tokens on the client side without the server having to generate them. "b" there is optional, but makes JWT a handy way to do a quick shared secret between servers to allow for trusted authentication.
It's a larger topic than that, for sure, but that's the quick and dirty of it.
Mixing It With An NSF
Normally on Domino, you're either authenticated for the whole server or you're not. That's usually fine - if you want to have a restricted account, you can specifically grant it access to only a few NSFs. However, it's good to be able to go more fine-grained, restricting even powerful accounts to only do certain things in some contexts.
So I had the notion to take the JWT capability and mix it with JavaSapi to allow you to do just that. The idea is this:
- You make a file resource (hidden from the web) named "jwt.txt" that contains your per-NSF secret.
- A remote client makes a request with an
Authorization
header in the form ofBearer Some.JWT.Here
- The JavaSapi interceptor sees this, checks the target NSF, loads the secret, verifies it against the token, and authorizes the user if it's legal
As it turns out, this turned out to be actually not that difficult in practice at all.
The main core of the code is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public int authenticate(IJavaSapiHttpContextAdapter context) { IJavaSapiHttpRequestAdapter req = context.getRequest(); // In the form of "/foo.nsf/bar" String uri = req.getRequestURI(); String secret = getJwtSecret(uri); if(StringUtil.isNotEmpty(secret)) { try { String auth = req.getHeader("Authorization"); //$NON-NLS-1$ if(StringUtil.isNotEmpty(auth) && auth.startsWith("Bearer ")) { //$NON-NLS-1$ String token = auth.substring("Bearer ".length()); //$NON-NLS-1$ Optional<String> user = decodeAuthenticationToken(token, secret); if(user.isPresent()) { req.setAuthenticatedUserName(user.get(), "JWT"); //$NON-NLS-1$ return HTEXTENSION_REQUEST_AUTHENTICATED; } } } catch(Throwable t) { t.printStackTrace(); } } return HTEXTENSION_EVENT_DECLINED; } |
To read the JWT secret, I used IBM's NAPI:
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 | private String getJwtSecret(String uri) { int nsfIndex = uri.toLowerCase().indexOf(".nsf"); //$NON-NLS-1$ if(nsfIndex > -1) { String nsfPath = uri.substring(1, nsfIndex+4); try { NotesSession session = new NotesSession(); try { if(session.databaseExists(nsfPath)) { // TODO cache lookups and check mod time NotesDatabase database = session.getDatabase(nsfPath); database.open(); NotesNote note = FileAccess.getFileByPath(database, SECRET_NAME); if(note != null) { return FileAccess.readFileContentAsString(note); } } } finally { session.recycle(); } } catch(Exception e) { e.printStackTrace(); } } return null; } |
And then, for the actual JWT handling, I use the auth0 java-jwt library:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public static Optional<String> decodeAuthenticationToken(final String token, final String secret) { if(token == null || token.isEmpty()) { return Optional.empty(); } try { Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .withIssuer(ISSUER) .build(); DecodedJWT jwt = verifier.verify(token); Claim claim = jwt.getClaim(CLAIM_USER); if(claim != null) { return Optional.of(claim.asString()); } else { return Optional.empty(); } } catch (IllegalArgumentException | UnsupportedEncodingException e) { throw new RuntimeException(e); } } |
And, with that in place, it works:
That text is coming from a LotusScript agent - as I mentioned in my original JavaSapi post, this authentication is trusted the same way DSAPI authentication is, and so all elements, classic or XPages, will treat the name as canon.
Because the token is based on the secret specifically from the NSF, using the same token against a different NSF (with no JWT secret or a different one) won't authenticate the user:
If we want to be fancy, we can call this scoped access.
This is the sort of thing that makes me want JavaSapi to be officially supported. Custom authentication and request filtering are much, much harder on Domino than on many other app servers, and JavaSapi dramatically reduces the friction.