posts found in this keyword

    JWKs and node-jose

    JWKs and node-jose

    After weeks of searching for documentation and examples on how to use node-Jose for:

    • Create an endpoint to expose the public part of the keys
    • Create an endpoint that returns a signed JWT with those keys
    • Validate the token issued as a client
    • Rotate the keys by an endpoint

    I found very little, so here’s how I did it.

    You are welcome to use https://github.com/ctrlTilde/node-api-skeleton as a skeleton, and all examples below will assume the same.

    Essentials

    In the base directory, create a new file, ‘keygen.js.’ We will use this quick little script to create a fresh set of keys.

    const fs = require('fs');
    const jose = require('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, '  ')
      )
    })

    Adding the null and empty space as the 2nd and 3rd arguments for the JSON stringify makes the files human readable. Adding true to the toJSON method will return not only the public but also the private section of the asymmetric key. We will be using the private key later to sign our tokens. The script will not replace the keys file; we will create a method in a while to allow for key rotation.

    JWKs endpoint

    We now have a key and can expose the public key in JSON format to an endpoint.

    router.get('/jwks', async(req, res) => {
      const ks = fs.readFileSync('keys.json')
      const keyStore = await jose.JWK.asKeyStore(ks.toString())
    
      res.send(keyStore.toJSON())
    })

    Like I mentioned earlier, 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 a result, you should see something like this:

    {
        keys: [
            {
                kty: "RSA",
                kid: "FMQFEi4xS3rvFaiyvEr6mwpDWYO6QVt30TBoADAyIx8",
                use: "sig",
                alg: "RS512",
                e: "AQAB",
                n: "vbNHkPnCGtUUCfcNa1MurqIRuWGhbMYPE_yxqRzbwflglCTbm5BAoJ7ou6WAGXFCVIrv6bX7XRATrnH_fhVq_Qhl8XC-iopoGRZEa3FGASrFCIGa2D1Y69f1avQlW3brp8jWoyeajImnIh-5QV7zmOLDFHUoZV2-yqHXlrNUK48MkKbKqM0aCHTt7bos7BCI2zG7wrJVIxUfUNVSxSd7PLgZ2GjHU7pPKfGyB5WgOCx_MXTpvANIzRDqrMyVUQ5OlvmXZEb3HUKoLlpdYGRqHqkkKTr_rRtp3lrhTgDBNp_jwdIrwENgFECYcvs-JEMWe-pod7zexuth8DQQSm86Ew"
            }
        ]
    }

    Let’s take a minute to look at the essential parts of the JSON response.

    The kid or Key IDentifier will later allow you to match the signature of your JWT, especially if you have multiple key pairs which follow the RFC7515 definition.

    Create a test token, and sign it

    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 straightforward

    • get the keys,
    • create a Keystore and
    • expose the key to sign the JWT.

    Now for the opt argument is worth noticing that it 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.

    Validation

    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:8080/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,

    • your endpoint will get a JSON with a token inside,
    • you have the URL for the jwks endpoint,
    • 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 an endpoint here to trigger the add/delete actions, but probably you should use a cronjob 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 function 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.delete('/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 allows 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.