From 885a00c3ae36fc364a1c570afb5920f92c1775a6 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 8 Mar 2019 12:30:32 -0700 Subject: [PATCH] v1.2.7: update docs, handle human readable 'exp' claims, denest required claims --- README.md | 67 +++++++++++++++++++++++++++++++++++++--------------- keypairs.js | 51 ++++++++++++++++++++++++++++++++++----- package.json | 3 +-- test.js | 14 +++++++++++ 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4c2b3b5..60a3953 100644 --- a/README.md +++ b/README.md @@ -29,25 +29,48 @@ and [Rasha.js (RSA)](https://git.coolaj86.com/coolaj86/rasha.js/). # Usage -A brief (albeit somewhat nonsensical) introduction to the APIs: +A brief introduction to the APIs: ``` +// generate a new keypair as jwk +// (defaults to EC P-256 when no options are specified) Keypairs.generate().then(function (pair) { - return Keypairs.export({ jwk: pair.private }).then(function (pem) { - return Keypairs.import({ pem: pem }).then(function (jwk) { - return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { - console.log(thumb); - return Keypairs.signJwt({ - jwk: keypair.private - , claims: { - iss: 'https://example.com' - , sub: 'jon.doe@gmail.com' - , exp: Math.round(Date.now()/1000) + (3 * 24 * 60 * 60) - } - }); - }); - }); - }); + console.log(pair.private); + console.log(pair.public); +}); +``` + +``` +// JWK to PEM +// (supports various 'format' and 'encoding' options) +return Keypairs.export({ jwk: pair.private, format: 'pkcs8' }).then(function (pem) { + console.log(pem); +}); +``` + +``` +// PEM to JWK +return Keypairs.import({ pem: pem }).then(function (jwk) { +}); +``` + +``` +// Thumbprint a JWK (SHA256) +return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { + console.log(thumb); +}); +``` + +``` +// Sign a JWT (aka compact JWS) +return Keypairs.signJwt({ + jwk: pair.private +, iss: 'https://example.com' +, exp: '1h' + // optional claims +, claims: { + , sub: 'jon.doe@gmail.com' + } }); ``` @@ -56,9 +79,9 @@ _much_ longer than RSA has, and they're smaller, and faster to generate. ## API Overview -* generate -* parse -* parseOrGenerate +* generate (JWK) +* parse (PEM) +* parseOrGenerate (PEM to JWK) * import (PEM-to-JWK) * export (JWK-to-PEM, private or public) * publish (Private JWK to Public JWK) @@ -155,11 +178,17 @@ Returns a JWT (otherwise known as a protected JWS in "compressed" format). ```js { jwk: jwk + // required claims +, iss: 'https://example.com' +, exp: '15m' + // all optional claims , claims: { } } ``` +Exp may be human readable duration (i.e. 1h, 15m, 30s) or a datetime in seconds. + Header defaults: ```js diff --git a/keypairs.js b/keypairs.js index c38ed10..0a290b1 100644 --- a/keypairs.js +++ b/keypairs.js @@ -103,10 +103,14 @@ Keypairs.neuter = Keypairs._neuter = function (opts) { Keypairs.publish = function (opts) { if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } + // returns a copy var jwk = Keypairs.neuter(opts); - if (!jwk.exp) { - if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } + if (jwk.exp) { + jwk.exp = setTime(jwk.exp); + } else { + if (opts.exp) { jwk.exp = setTime(opts.exp); } + else if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } else { jwk.exp = opts.expiresAt; } } if (!jwk.use && false !== jwk.use) { jwk.use = "sig"; } @@ -134,17 +138,22 @@ Keypairs.signJwt = function (opts) { if (!header.kid) { header.kid = thumb; } - if (false === claims.iat) { + if (!claims.iat && (false === claims.iat || false === opts.iat)) { claims.iat = undefined; } else if (!claims.iat) { claims.iat = Math.round(Date.now()/1000); } - if (false === claims.exp) { + + if (opts.exp) { + claims.exp = setTime(opts.exp); + } else if (!claims.exp && (false === claims.exp || false === opts.exp)) { claims.exp = undefined; } else if (!claims.exp) { - throw new Error("opts.claims.exp should be the expiration date (as seconds since the Unix epoch) or false"); + throw new Error("opts.claims.exp should be the expiration date as seconds, human form (i.e. '1h' or '15m') or false"); } - if (false === claims.iss) { + + if (opts.iss) { claims.iss = opts.iss; } + if (!claims.iss && (false === claims.iss || false === opts.iss)) { claims.iss = undefined; } else if (!claims.iss) { throw new Error("opts.claims.iss should be in the form of https://example.com/, a secure OIDC base url"); @@ -229,6 +238,36 @@ Keypairs.signJws = function (opts) { }); }; +function setTime(time) { + if ('number' === typeof time) { return time; } + + var t = time.match(/^(\-?\d+)([dhms])$/i); + if (!t || !t[0]) { + throw new Error("'" + time + "' should be datetime in seconds or human-readable format (i.e. 3d, 1h, 15m, 30s"); + } + + var now = Math.round(Date.now()/1000); + var num = parseInt(t[1], 10); + var unit = t[2]; + var mult = 1; + switch(unit) { + // fancy fallthrough, what fun! + case 'd': + mult *= 24; + /*falls through*/ + case 'h': + mult *= 60; + /*falls through*/ + case 'm': + mult *= 60; + /*falls through*/ + case 's': + mult *= 1; + } + + return now + (mult * num); +} + Enc.strToUrlBase64 = function (str) { // node automatically can tell the difference // between uc2 (utf-8) strings and binary strings diff --git a/package.json b/package.json index 6b309f3..ad455e1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { "name": "keypairs", - "version": "1.2.6", + "version": "1.2.7", "description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM", "main": "keypairs.js", "files": [ - "CLI.md", "bin/keypairs.js" ], "scripts": { diff --git a/test.js b/test.js index c10a4aa..73bdb0f 100644 --- a/test.js +++ b/test.js @@ -90,6 +90,20 @@ Keypairs.parseOrGenerate({ key: '' }).then(function (pair) { if ('NOERR' === e.code) { throw e; } return true; }) + , Keypairs.signJwt({ jwk: pair.private, iss: 'https://example.com/', exp: '1h' }).then(function (jwt) { + var parts = jwt.split('.'); + var now = Math.round(Date.now()/1000); + var token = { + header: JSON.parse(Buffer.from(parts[0], 'base64')) + , payload: JSON.parse(Buffer.from(parts[1], 'base64')) + , signature: parts[2] //Buffer.from(parts[2], 'base64') + }; + // allow some leeway just in case we happen to hit a 1ms boundary + if (token.payload.exp - now > 60 * 59.99) { + return true; + } + throw new Error("token was not properly generated"); + }) ]).then(function (results) { if (results.length && results.every(function (v) { return true === v; })) { console.info("If a warning prints right above this, it's a pass");