After weeks of searching for documentation and examples on how to use node-jose for:
- Create a /jwks endpoint to expose the public part of the keys
- Create a /tokens endpoint that returns a signed JWT with those keys
- Validate the token issued as a client
- Rotate the keys exposed on the /jwks endpoint
I found very little so here’s how I did it
JWKs endpoint
We are going to need a few things before exposing this endpoint in your app, the first one is definitely generating the keys.
const fs = require('fs');
const jose = require('node-jose');const keyStore = jose.JWK.createKeyStore()keyStore.generate('RSA', 2048, {alg: 'RS256', use: 'sig' })
.then(result => {
fs.writeFileSync(
'keys.json',
JSON.stringify(keyStore.toJSON(true), null, ' ')
)
})
you don’t need to add null and ‘empty-space’ as 2nd and 3rd argument for the JSON stringify but I really like to keep my files readable for the human eye, and I’m passing the true
to the toJSON(true)
method, because this flag will return the public but also the private section of the asymmetric key and we will use the private key later to sign the tokens.
now that we have the keys.json
file ready to be exposed let’s return the public key on a json form using expressJS:
router.get('/jwks', async (req, res) => {
const ks = fs.readFileSync('keys.json')
const keyStore = await jose.JWK.asKeyStore(ks.toString())
res.send(keyStore.toJSON())
})
this time opposed to the key creation we’re not going to use true
inside the toJSON()
method because we’re only looking to expose the public key. As result, you should see something like this.
{
"keys": [
{
"kty": "RSA",
"kid": "IiI4ffge7LZXPztrZVOt26zgRt0EPsWPaxAmwhbJhDQ",
"use": "sig",
"alg": "RS256",
"e": "AQAB",
"n": "1Sn1X_y-RUzGna0hR00Wu64ZtY5N5BVzpRIby9wQ5EZVyWL9DRhU5PXqM3Y5gzgUVEQu548qQcMKOfs46PhOQudz-HPbwKWzcJCDUeNQsxdAEhW1uJR0EEV_SGJ-jTuKGqoEQc7bNrmhyXBMIeMkTeE_-ys75iiwvNjYphiOhsokC_vRTf_7TOPTe1UQasgxEVSLlTsen0vtK_FXcpbwdxZt02IysICcX5TcWX_XBuFP4cpwI9AS3M-imc01awc1t7FE5UWp62H5Ro2S5V9YwdxSjf4lX87AxYmawaWAjyO595XLuIXA3qt8-irzbCeglR1-cTB7a4I7_AclDmYrpw"
}
}
so let me explain a few of the important parts from this json response, beginning with the kid
, this is the key identifier that will allow you later if there is more than one key inside the array (and there will be when it's time to rotate the key) to match the signature of your JWT with the corresponding public key to do the verification. https://tools.ietf.org/html/rfc7517#section-4.5
/tokens endpoint and how to sign
router.get('/tokens', async (req, res) => {
const ks = fs.readFileSync('keys.json')
const keyStore = await jose.JWK.asKeyStore(ks.toString())
const [key] = keyStore.all({ use: 'sig' })
const opt = { compact: true, jwk: key, fields: { typ: 'jwt' } }
const payload = JSON.stringify({
exp: Math.floor((Date.now() + ms('1d')) / 1000),
iat: Math.floor(Date.now() / 1000),
sub: 'test',
}) const token = await jose.JWS.createSign(opt, key)
.update(payload)
.final() res.send({ token })
})
The initial part is very straight forward 1 get the keys, 2 create a keyStore and 3 expose the key to sign the JWT. Now for the opt
argument is worth noticing that includes compact: true
and fields typ: 'jwt'
that helps us to follow the JWT standard and after we return the token we can double-check the purpose of those fields in http://jwt.io, It’s also worth mentioning that the iat
and exp
(for issued_at and expiration respectively) include a Math floor and division over 1000 because the standard of JWT talks about time in seconds and JS exposes milliseconds by default.
Validate the token
There’s no need to actually follow the validate part inside your own app, given that you will only validate the JWT if you’re a client of the tokens. but for those interested here is how validation occurs.
const jwktopem = require('jwk-to-pem')
const jwt = require('jsonwebtoken')router.post('/verify', async (req, res) => {
const { token } = req.body
const { data } = await axios.get('http://localhost:4040/jwks')
const [ firstKey ] = data.keys
const publicKey = jwktopem(firstKey) try {
const decoded = jwt.verify(token, publicKey)
res.send(decoded)
} catch (e) {
res.send({ error: e })
}
})
let’s clarify first the assumptions, 1 your endpoint will get a json with a token inside, 2 you have the URL for the jwks endpoint, 3 at this point the /jwks endpoint only has one key therefore you don’t have to iterate the array trying to match the kid
that is inside the header of your token. With the previous portion of code you can play around for instance emitting JWT that are already expired.
Key Rotation
Finally, we got to the core of the article. let’s set the expectations, what we want here is to be able to sign JWTs with a different key but also allow the clients that have previously signed JWTs to verify with the help of the /jwks endpoint and after all the clients can’t possibly have an old token (after 24h given the expiration time that we set) we will delete the unused key.
I’m going to use here an endpoint to trigger the add/delete actions but probably you should use a chronjob or any other method that is not exposed.
app.get('/add', async (req, res) => {
const ks = fs.readFileSync('keys.json')
const keyStore = await jose.JWK.asKeyStore(ks.toString()) await keyStore.generate('RSA', 2048, { alg: 'RS256', use: 'sig' })
const json = keyStore.toJSON(true) json.keys = json.keys.reverse() fs.writeFileSync('keys.json', JSON.stringify(json, null, ' '))
res.send(keyStore.toJSON())
})
With this fn we are trying to add a new key to the current array; when you execute the .generate()
method for a previously created store like in this case (we replicate the existent store from the keys.json) then it will add a new key to that array, and after that what I do is reverse
the order of keys before saving to keep the most up-to-date key first so when I try to sign the next token I can continue using the destructuring as const [key] = keyStore.all...
to get the first key without having to modify the /tokens endpoint to continue signing keys with the latest certificate.
Now to implement the delete key portion (we should trigger that after the maximum time that we apply to the tokens in our case 24h) all we need is plain JS but I’ll use a little bit of node-jose just to return the result and check that is working.
app.get('/del', async (req, res) => {
const ks = JSON.parse(fs.readFileSync('keys.json')) if (ks.keys.length > 1) ks.keys.pop() fs.writeFileSync('keys1.json', JSON.stringify(ks, null, ' ')) const keyStore = await jose.JWK.asKeyStore(JSON.stringify(ks))
res.send(keyStore.toJSON())
})
remember that in the previous section, there’s a json.keys.reverse()
? that portion of the code allow us now to just do a .pop()
to the array to remove the last key and then we save it again to the keys.json file, and for the sake of this article/tutorial we create a keyStore and expose the public part to double-check that there’s only one key standing and is the new one.
NOTES: for the time when there are two keys exposed, after we trigger the /add endpoint but before we trigger the /del; the client will need to figure what’s the corresponding key to its JWT, the usual solution is to iterate over the array and stop to validate when the kid
is a match.
Maybe I should publish a repo later 😅