ltijs
Version:
Turn your application into a fully integratable LTI 1.3 tool or platform.
278 lines (241 loc) • 9.45 kB
JavaScript
;
const crypto = require('crypto');
const jwk = require('pem-jwk');
const got = require('got');
const find = require('lodash.find');
const jwt = require('jsonwebtoken');
const provAuthDebug = require('debug')('provider:auth'); // const cons_authdebug = require('debug')('consumer:auth')
/**
* @description Authentication class manages RSA keys and validation of tokens.
*/
class Auth {
/**
* @description Generates a new keypairfor the platform.
* @param {String} ENCRYPTIONKEY - Encryption key.
* @returns {String} kid for the keypair.
*/
static async generateProviderKeyPair(ENCRYPTIONKEY, Database) {
let kid = crypto.randomBytes(16).toString('hex');
while (await Database.Get(false, 'publickey', {
kid: kid
})) {
kid = crypto.randomBytes(16).toString('hex');
}
const keys = crypto.generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem'
}
});
const {
publicKey,
privateKey
} = keys;
const pubkeyobj = {
key: publicKey,
kid: kid
};
const privkeyobj = {
key: privateKey,
kid: kid
};
await Database.Insert(ENCRYPTIONKEY, 'publickey', pubkeyobj, {
kid: kid
});
await Database.Insert(ENCRYPTIONKEY, 'privatekey', privkeyobj, {
kid: kid
});
return kid;
}
/**
* @description Resolves a promisse if the token is valid following LTI 1.3 standards.
* @param {String} token - JWT token to be verified.
* @param {String} state - State validation parameter.
* @param {Object} validationParameters - Stored validation parameters retrieved from cookies.
* @param {Function} getPlatform - getPlatform function to get the platform that originated the token.
* @param {String} ENCRYPTIONKEY - Encription key.
* @returns {Promise}
*/
static async validateToken(token, state, validationParameters, getPlatform, ENCRYPTIONKEY, logger, Database) {
const decodedToken = jwt.decode(token, {
complete: true
});
const kid = decodedToken.header.kid;
const alg = decodedToken.header.alg;
provAuthDebug('Attempting to validate iss claim');
provAuthDebug('Request Iss claim: ' + validationParameters.iss);
provAuthDebug('Response Iss claim: ' + decodedToken.payload.iss);
if (!validationParameters.iss || validationParameters.iss !== decodedToken.payload.iss) throw new Error('IssClaimDoesNotMatch');
provAuthDebug('Attempting to validate state');
provAuthDebug('Request state: ' + validationParameters.state);
provAuthDebug('Response state: ' + state);
if (!validationParameters.state || validationParameters.state !== state) throw new Error('StateClaimDoesNotMatch');
provAuthDebug('Attempting to retrieve registered platform');
const platform = await getPlatform(decodedToken.payload.iss, ENCRYPTIONKEY, logger, Database);
if (!platform) throw new Error('NoPlatformRegistered');
const authConfig = await platform.platformAuthConfig();
switch (authConfig.method) {
case 'JWK_SET':
{
provAuthDebug('Retrieving key from jwk_set');
if (!kid) throw new Error('NoKidFoundInToken');
const keysEndpoint = authConfig.key;
const res = await got.get(keysEndpoint);
const keyset = JSON.parse(res.body).keys;
if (!keyset) throw new Error('NoKeySetFound');
const key = jwk.jwk2pem(find(keyset, ['kid', kid]));
if (!key) throw new Error('NoKeyFound');
const verified = await this.verifyToken(token, key, alg, platform, Database);
return verified;
}
case 'JWK_KEY':
{
provAuthDebug('Retrieving key from jwk_key');
if (!authConfig.key) throw new Error('NoKeyFound');
const key = jwk.jwk2pem(authConfig.key);
const verified = await this.verifyToken(token, key, alg, platform, Database);
return verified;
}
case 'RSA_KEY':
{
provAuthDebug('Retrieving key from rsa_key');
const key = authConfig.key;
if (!key) throw new Error('NoKeyFound');
const verified = await this.verifyToken(token, key, alg, platform, Database);
return verified;
}
default:
{
provAuthDebug('No auth configuration found for platform');
throw new Error('NoAuthConfigFound');
}
}
}
/**
* @description Verifies a token.
* @param {Object} token - Token to be verified.
* @param {String} key - Key to verify the token.
* @param {String} alg - Algorithm used.
* @param {Platform} platform - Issuer platform.
*/
static async verifyToken(token, key, alg, platform, Database) {
provAuthDebug('Attempting to verify JWT with the given key');
const decoded = jwt.verify(token, key, {
algorithms: [alg]
});
await this.oidcValidationSteps(decoded, platform, alg, Database);
return decoded;
}
/**
* @description Validates de token based on the OIDC specifications.
* @param {Object} token - Id token you wish to validate.
* @param {Platform} platform - Platform object.
* @param {String} alg - Algorithm used.
*/
static async oidcValidationSteps(token, platform, alg, Database) {
provAuthDebug('Token signature verified');
provAuthDebug('Initiating OIDC aditional validation steps');
const aud = this.validateAud(token, platform);
const _alg = this.validateAlg(alg);
const iat = this.validateIat(token);
const nonce = this.validateNonce(token, Database);
return Promise.all([aud, _alg, iat, nonce]);
}
/**
* @description Validates Aud.
* @param {Object} token - Id token you wish to validate.
* @param {Platform} platform - Platform object.
*/
static async validateAud(token, platform) {
provAuthDebug("Validating if aud (Audience) claim matches the value of the tool's clientId given by the platform");
provAuthDebug('Aud claim: ' + token.aud);
provAuthDebug("Tool's clientId: " + (await platform.platformClientId()));
if (!token.aud.includes((await platform.platformClientId()))) throw new Error('AudDoesNotMatchClientId');
if (Array.isArray(token.aud)) {
provAuthDebug('More than one aud listed, searching for azp claim');
if (token.azp && token.azp !== (await platform.platformClientId())) throw new Error('AzpClaimDoesNotMatchClientId');
}
return true;
}
/**
* @description Validates Aug.
* @param {String} alg - Algorithm used.
*/
static async validateAlg(alg) {
provAuthDebug('Checking alg claim. Alg: ' + alg);
if (alg !== 'RS256') throw new Error('NoRSA256Alg');
return true;
}
/**
* @description Validates Iat.
* @param {Object} token - Id token you wish to validate.
*/
static async validateIat(token) {
provAuthDebug('Checking iat claim to prevent old tokens from being passed.');
provAuthDebug('Iat claim: ' + token.iat);
const curTime = Date.now() / 1000;
provAuthDebug('Current_time: ' + curTime);
const timePassed = curTime - token.iat;
provAuthDebug('Time passed: ' + timePassed);
if (timePassed > 10) throw new Error('TokenTooOld');
return true;
}
/**
* @description Validates Nonce.
* @param {Object} token - Id token you wish to validate.
*/
static async validateNonce(token, Database) {
provAuthDebug('Validating nonce');
provAuthDebug('Nonce: ' + token.nonce);
if (await Database.Get(false, 'nonce', {
nonce: token.nonce
})) throw new Error('NonceAlreadyStored');
provAuthDebug('Storing nonce');
await Database.Insert(false, 'nonce', {
nonce: token.nonce
});
return true;
}
/**
* @description Gets a new access token from the platform.
* @param {Platform} platform - Platform object of the platform you want to access.
*/
static async getAccessToken(platform, ENCRYPTIONKEY, Database) {
const confjwt = {
iss: await platform.platformClientId(),
sub: await platform.platformClientId(),
aud: await platform.platformAccessTokenEndpoint(),
iat: Date.now() / 1000,
exp: Date.now() / 1000 + 60,
jti: crypto.randomBytes(16).toString('base64')
};
const token = jwt.sign(confjwt, (await platform.platformPrivateKey()), {
algorithm: 'RS256',
keyid: await platform.platformKid()
});
const message = {
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: token,
scope: 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'
};
provAuthDebug('Awaiting return from the platform');
const res = await got.post((await platform.platformAccessTokenEndpoint()), {
form: message
});
provAuthDebug('Successfully generated new access_token');
const access = JSON.parse(res.body);
await Database.Insert(ENCRYPTIONKEY, 'accesstoken', {
token: access
}, {
platformUrl: await platform.platformUrl()
});
return access;
}
}
module.exports = Auth;