@adicitus/jwtgenerator
Version:
Utility to easily generate/verify JWT using public key cryptography.
239 lines (207 loc) • 9.71 kB
JavaScript
"strict"
const jwt = require('jsonwebtoken')
const {v4: uuidv4} = require('uuid')
const Crypto = require('crypto')
const { DateTime, Duration } = require('luxon')
/**
* Class to facilitate the creation and verification JSON Web Tokens using public key cryptography.
*
* Tokens generated by this class enforce the inclusion and verification of subjext ("sub"), issuer ("iss") and key id ("kid") fields.
*/
class JWTGenerator {
/**
* Key length to use when generating new key pairs.
*/
keyLength = 2048
/**
* Signing algorithm used when generating the tokens.
*
* For a list of supported algorithms see: [https://github.com/auth0/node-jws#jwsalgorithms](https://github.com/auth0/node-jws#jwsalgorithms).
*/
algorithm = 'RS256'
/**
* Luxon Duration object representing the default lifetime of tokens generated.
*/
tokenLifetime = null
/**
* ID of this generator. This value will be included as the issuer of any tokens generated.
*/
id = null
/**
* The current public key used when generating tokens.
*/
#publicKey = null
/**
* The current private key used when generating tokens.
*/
#privateKey = null
/**
* Interval object used to continuously update key pair.
*/
#keyUpdateInterval = null
/**
* MongoDB collection used to store token verification records.
*/
#tokenCollection = null
/**
* Create a new token generator.
*
* @param {object} options - Additional options to customize the generator.
* - **id**: Manually assigned id for this generator, this value will be included as the issuer ("iss") of any tokens generated. If not specified, a UUID will be generated.
* - **collection**: MongoDB collection to record tokens in. If this is not provided then records must be stored and retrieved manualy.'
* - **keyLifetime**: How often the keys should be regenerated (luxon duration object). Setting this to 0 or less will cause the keys to be regenerated after each new token.
* - **tokenLifetime**: How long the tokens should remain valid by default (luxon duration object).
*/
constructor(options) {
this.id = uuidv4()
this.tokenLifetime = Duration.fromObject({ minutes: 30 })
let keyLifetime = Duration.fromObject({minutes: 60})
if (options) {
if (options.id) {
this.id = options.id
}
if (options.collection) {
this.tokenCollection = options.collection
}
if (options.keyLifetime !== undefined) {
if (options.keyLifetime.isLuxonDuration) {
keyLifetime = new Duration(options.keyLifetime)
} else {
keyLifetime = Duration.fromObject(options.keyLifetime)
}
}
if (options.tokenLifetime) {
if (options.tokenLifetime.isLuxonDuration) {
this.tokenLifetime = new Duration(options.tokenLifetime)
} else {
this.tokenLifetime = Duration.fromObject(options.tokenLifetime)
}
}
}
this.generateKeys()
if (keyLifetime.toMillis() > 0) {
this.keyUpdateInterval = setInterval(() => this.generateKeys(), keyLifetime.toMillis())
}
}
/**
* Regenerates the DSA key pair used to generate tokens.
*/
generateKeys() {
let keyPair = Crypto.generateKeyPairSync('rsa', { modulusLength: this.keyLength })
this.publicKey = keyPair.publicKey.export({ type: 'spki', format: 'pem' })
this.privateKey = keyPair.privateKey.export({ type: 'pkcs8', format:'pem' })
}
/**
* Used to generate a token for the provided subject.
*
* @param {object} subject - Subject authenticated by this token.
* @param {object} options - Additional options.
* - **duration**: A luxon duration to to define how long the token should be valid.
* This can be used to override the default set when the instance is created, but should otherwise not be used.
* - **payload**: An object with fields/values that should included in the payload, this can be used to include additional custom claims.
* This cannot be used to overwrite the subject ("sub"), issuer ("iss"), issued at ("iat") or expires ("exp") fields.
* @returns {object} Object containing 2 properties: "record" and "token".
* - **record**: An object containing information necessary to verify the validity of the token, and should be stored by the server.
* - If the generator has been set up with a MongoDB collection, then the record will automatically be stored there.
* - **token**: A string representation of the token, this should be passed to the client.
*/
async newToken(subject, options) {
let now = DateTime.now()
let duration = this.tokenLifetime
var payload = {
sub: subject,
iss: this.id
}
if (options) {
if (options.duration) {
if (options.duration.isLuxonDuration) {
duration = new Duration(options.duration)
} else {
duration = Duration.fromObject(options.duration)
}
}
if(options.payload) {
for (const claim in options.payload) {
if ((null == payload[claim])) {
payload[claim] = options.payload[claim]
}
}
}
}
let validTo = now.plus(duration)
var tokenRecord = {
id: uuidv4(),
subject: subject,
issuer: this.id,
key: this.publicKey,
issued: now,
expires: validTo
}
var token = jwt.sign(payload, this.privateKey, {algorithm: this.algorithm, expiresIn: `${duration.as('hour')}h`, keyid: tokenRecord.id})
if (this.tokenCollection) {
var currentTokenRecord = await this.tokenCollection.findOne({subject: subject})
if (currentTokenRecord) {
await this.tokenCollection.replaceOne({id: currentTokenRecord.id}, tokenRecord)
} else {
await this.tokenCollection.insertOne(tokenRecord)
}
}
if (!this.keyUpdateInterval) {
this.generateKeys()
}
return { record: tokenRecord, token: token }
}
/**
* Attempts to validate the provided token.
*
* Returns an object with the subject of the token if successful.
*
* Otherwise returns an object with an error status and reason.
*
* @param {string} token - Token to validate.
* @param {object} options - Additional options.
* - **record**: A token record object used to verify the token. Used to debug the generator without a MongoDB collection.
* @returns {object} An object describing the state of the verification, it may contain the following fields:
* - **success**: A boolean describing whether the token was successfully verified.
* - **subject**: If the token was verified successfully, this field will indicate the identity of the client.
* - This is the same value as the one in the payload.
* - **payload**: If the token was verified successfully, The full payload field from the token.
* - **status**: If the verification failed, this short string indicates what caused the failure.
* - noRecordError: No record source available or couldn't find a matching record.
* - invalidRecordError: A record was found but is missing the Key ID, Issuer or Subject values.
* - invalidTokenError: The token could not be verified using the key on record or has expired.
* - **reason**: If the verification failed, this property may be included to provide a more user-friendly description of what caused the verification to fail.
*/
async verifyToken(token, options) {
try {
let {header, payload} = jwt.decode(token, {complete: true})
let tokenRecord = null
if (options && options.record) {
tokenRecord = options.record
} else if (this.tokenCollection) {
tokenRecord = await this.tokenCollection.findOne({id: header.kid})
} else {
return { success: false, status: 'noRecordError', reason: 'No record source available.' }
}
if (!tokenRecord) {
return { success: false, status: 'noRecordError', reason: `No record found for the token (ID: '${header.kid}').` }
}
if (!(tokenRecord.key && tokenRecord.issuer && tokenRecord.subject)) {
return { success: false, status: 'invalidRecordError', reason: `Token record is incomplete (ID: '${header.kid}').` }
}
jwt.verify(token, tokenRecord.key, {issuer: tokenRecord.issuer, subject: tokenRecord.subject})
let r = { success: true, subject: tokenRecord.subject, payload: payload }
return r
} catch {
return { success: false, status: 'invalidTokenError', reason: 'Token does not match key on record or is expired.' }
}
}
/**
* Stops the regular regeneration of the key pair (if applicable).
*/
async dispose() {
clearInterval(this.keyUpdateInterval)
this.keyUpdateInterval = null
}
}
module.exports = JWTGenerator