oidc-lib
Version:
A library for creating OIDC Service Providers
722 lines (593 loc) • 23.9 kB
JavaScript
module.exports = {
init: init
};
var debugging_credential_issuer = false;
function init(global_pk, module_name){
var instance = new OIDC_CI(global_pk, module_name);
return instance;
}
class OIDC_CI {
constructor(global_pk, module_name){
this.pk = global.pk;
this.module_name= module_name;
this.cookieId = module_name + '_token';
this.oidc_config = pk.util.config.content_modules[module_name].config;
var thisClass = this;
this.adc_methods = {
"frozen": "ADC token is generated and when received by the SIOP is used until expiry",
"frozen-refresh": "ADC token is generated and when received by the SIOP is used until refresh time",
"realtime": "ADC token is fetched from the issuer during each presentation",
"realtime-encrypted": "Not currently implemented: An encrypted ADC token is fetched fromt he issuer during each presentation"
};
this.api_entryPoints;
var co = {
origin: function (origin, callback) {
var whitelist = [];
if (whitelist.indexOf(origin) === -1) {
callback(null, true)
}
else {
callback(new Error('Not allowed by CORS'))
}
}
}
}
registerApi(entryPoints){
if (!entryPoints['get_claims']){
throw("registerApi entryPoints must include 'get_claims'");
}
this.api_entryPoints = entryPoints;
}
getResource(requestedResource){
var resource;
var contentDataPath = process.cwd() + '/' + this.module_name + '\\data\\';
if (pk.util.isUnixFilesystem()){
contentDataPath = pk.util.forwardSlash(contentDataPath);
}
var contentUiPath = process.cwd() + '\\web\\' + this.module_name + '\\';
if (pk.util.isUnixFilesystem()){
contentUiPath = pk.util.forwardSlash(contentUiPath);
}
switch (requestedResource){
case 'card_design':
var resource_path = contentUiPath + 'issuerCard_350.jpg';
try {
var resourceBuffer = pk.fs.readFileSync(resource_path);
resource = 'data:image/jpeg;base64,' + resourceBuffer.toString('base64');
}
catch(err){
resource = '';
}
break;
case 'scope_claim_map':
resource = pk.fs.readFileSync(contentDataPath + 'scope_claim_map.json', {encoding: 'utf8'});
break;
case 'wallet_render':
resource = pk.fs.readFileSync(contentDataPath + 'wallet_render.html', {encoding: 'utf8'});
break;
case 'display_render':
try {
resource = pk.fs.readFileSync(contentDataPath + 'display_render.js', {encoding: 'utf8'});
}
catch (err){
}
break;
case 'recommended_apps':
try {
resource = pk.fs.readFileSync(contentDataPath + 'recommended_apps.json', {encoding: 'utf8'});
}
catch (err){
}
break;
default:
pk.util.log_error('getResource', 'No support for resource type: ' + requestedResource);
resource = {};
}
return resource;
}
// async executeBasicAdcRequest(req, res, stateObj, params, config_override){
async executeBasicAdcRequest(req, res, stateObj, ancillaryClaims){
var possible_error;
try{
var credential_metadata = await this.createOrLoadCredentialMetadata(stateObj, ancillaryClaims);
if (!this.api_entryPoints || !this.api_entryPoints['get_claims']){
throw('The credential issuer api must be registered and include the get_claims entryPoint');
}
if (credential_metadata.status !== 'enabled'){
var error = {
error: 'access_denied',
error_description:'CREDENTIAL_REVOKED: The credential has been revoked and cannot be accessed or modified'
}
throw (error);
}
var adcOptions = this.defaultAdcOptions(stateObj);
credential_metadata = await this.processAdcRequest(credential_metadata, stateObj, adcOptions);
await this.set_persona_info(credential_metadata, stateObj, adcOptions);
// save the credential metadata
possible_error = 'error updating cred_metadata';
await pk.dbs[this.module_name].provider.createOrUpdateDocument(
pk.dbs[this.module_name], 'cred_metadata', credential_metadata);
// set the content_module_state for access token management etc
var content_module_state = this.set_content_module_state(stateObj, credential_metadata);
possible_error = 'error applying AuthResponse';
pk.sts.applyAuthResponse(res, stateObj.encoded_sts_state_bundle, content_module_state);
}
catch(err){
var nonce;
if (stateObj.params.nonce){
nonce = stateObj.params.nonce;
}
if (typeof err === 'string'){
err = {
error: 'server_error',
error_descrption: err
}
}
if (nonce){
err.nonce = nonce;
}
var content_module_state = this.set_content_module_state(stateObj, credential_metadata);
content_module_state.error = err;
pk.sts.applyAuthResponse(res, stateObj.encoded_sts_state_bundle, content_module_state);
}
}
async set_persona_info(credential_metadata, stateObj, adcOptions){
if (!credential_metadata.persona_info){
credential_metadata.persona_info = {};
}
var resources = 'card_design scope_claim_map wallet_render display_render recommended_apps';
// credential_metadata.persona_info.scope = stateObj.config.scope;
credential_metadata.persona_info.card_title = stateObj.config.card_title;
credential_metadata.persona_info.resources = resources;
credential_metadata.persona_info.output_credential_description = adcOptions.output_credential_description;
credential_metadata.persona_info.output_credential_colors = stateObj.config.output_credential_colors;
if (stateObj.config.card_text){
credential_metadata.persona_info.card_text = JSON.stringify(stateObj.config.card_text);
}
if (stateObj.params.iwi){
credential_metadata.persona_info.iwi = stateObj.params.iwi;
}
}
async executeCredentialIssuerAPI(req, res, stateObj, ancillaryClaims){
var possible_error = 'Error in executeCredentialIssuerAPI: ';
try{
if (!this.api_entryPoints || !this.api_entryPoints['get_claims']){
throw('The credential issuer api must be registered and include the get_claims entryPoint');
}
var credential_metadata = await this.createOrLoadCredentialMetadata(stateObj, ancillaryClaims);
var adcOptions = this.defaultAdcOptions(stateObj);
credential_metadata = await this.processAdcRequest(credential_metadata, stateObj, adcOptions);
await this.set_persona_info(credential_metadata, stateObj, adcOptions, ancillaryClaims);
// save the credential metadata
possible_error = 'error updating cred_metadata';
await pk.dbs[this.module_name].provider.createOrUpdateDocument(
pk.dbs[this.module_name], 'cred_metadata', credential_metadata);
var content_module_state = this.set_content_module_state(stateObj, credential_metadata);
pk.sts.applyAuthResponse(res, stateObj.encoded_sts_state_bundle, content_module_state);
}
catch(err){
var error_message = possible_error + err;
throw (error_message);
}
}
set_content_module_state(stateObj, credential_metadata){
var content_module_state = {};
content_module_state.accessTokenContent = stateObj.accessTokenContent;
content_module_state.sub = credential_metadata.id;
content_module_state.scope = stateObj.config.scope;
if (!content_module_state.scope){
content_module_state.scope = stateObj.credential_uri;
}
var tokenClaims = {};
tokenClaims['issuer_name'] = stateObj.config.company_name;
tokenClaims['vc_constants'] = credential_metadata.vc_constants;
tokenClaims['claim_source'] = credential_metadata.claim_source;
if (credential_metadata.persona_info){
tokenClaims['persona_info'] = credential_metadata.persona_info;
}
tokenClaims['sub'] = credential_metadata.id;
content_module_state.newIdTokenContent = tokenClaims;
return content_module_state;
}
defaultAdcOptions(stateObj){
var adcOptions = {};
var mandatory_config = [ 'output_credential_type', 'output_credential_description'];
for (var i=0; i<mandatory_config.length; i++){
if (!stateObj.config[mandatory_config[i]]){
throw('credential issuer config for ' + stateObj.config.variant
+ ' must contain ' + mandatory_config[i]);
}
}
adcOptions.output_credential_type = stateObj.config.output_credential_type;
adcOptions.outpyt_credential_context = stateObj.config.output_credential_context;
adcOptions.output_credential_description = stateObj.config.output_credential_description;
adcOptions.adc_method = stateObj.config.adc_method;
return adcOptions;
}
async processAdcRequest(credential_metadata, stateObj, options){
var claim_source_content;
var content_module_state = {};
var access_token_content = null;
var access_token = stateObj.access_token;
var output_credential_description;
var adc_method;
if (options.adc_method){
adc_method = options.adc_method;
}
if (!adc_method){
adc_method = "realtime";
}
adc_method = this.validateAdcMethod(adc_method);
var context = [ 'https://www.w3.org/2018/credentials/v1' ];
if (options.output_credential_context){
context.push(options.output_credential_context);
}
credential_metadata.vc_constants['@options'] = context;
credential_metadata.vc_constants.type = [ 'VerifiableCredential', options.output_credential_type ];
credential_metadata.vc_constants.description = options.output_credential_description;
// TODO: replace this with an expiration date
var expiryDate = 0;
claim_source_content = {
holder_sub: credential_metadata.id,
access_token: stateObj.access_token
};
access_token_content = {
id: stateObj.access_token,
holder_sub: credential_metadata.id,
expires: expiryDate
// claim_name: options.claim_name
};
claim_source_content.expires = expiryDate;
claim_source_content.credential_type = adc_method;
claim_source_content.credential_endpoint = stateObj.credential_uri;
credential_metadata.claim_source = claim_source_content;
stateObj.accessTokenContent = access_token_content;
return credential_metadata;
}
createCredentialMetadata(stateObj, ancillaryClaims){
var wallet;
if (stateObj.params && stateObj.params.client_id){
wallet = stateObj.params.client_id;
}
var credential_metadata = {
id: stateObj.sub,
vc_constants: {},
claim_source: {},
ancillaryClaims: ancillaryClaims,
wallet: wallet,
status: 'enabled'
};
return credential_metadata;
}
async createOrLoadCredentialMetadata(stateObj, ancillaryClaims){
var possible_error = 'Unexpected error reading cred_metadata';
var credential_metadata;
try{
credential_metadata = await pk.dbs[this.module_name].provider.getDocument(
pk.dbs[this.module_name], 'cred_metadata', stateObj.sub);
}
catch(err){
if (err.code !== 'ENOENT'){
throw(err);
}
return this.createCredentialMetadata(stateObj, ancillaryClaims);
}
return credential_metadata;
}
validateAdcMethod(method){
if (this.adc_methods[method] === undefined){
var values = '';
var sep = '';
for (var key in this.methods){
values += sep + key;
sep = ', ';
}
throw("ADC_METHOD: '" + method + "' is not a valid method (" + values + ")" );
}
return method;
}
invokeConsentUserAgent(res, scopeInfo, registrationInfo, encoded_sts_state_bundle, content_module_state) {
content_module_state.consentInfo = proxyIntrinsicallyGrantedConsent(scopeInfo, content_module_state);
this.pk.sts.applyConsentResponse(res, encoded_sts_state_bundle, content_module_state);
}
async generateCredentialResponse(res, params, consentInfo, content_module_state){
var potentialError = 'initializing';
try {
this.pk.util.log_debug('--- CREDENTIAL ISSUER: GENERATE CREDENTIAL RESPONSE ---');
this.pk.util.log_debug('Generating credential response for ' + this.module_name);
this.pk.util.log_detail('sub of holder at issuer', content_module_state.sub);
var config = this.pk.util.get_oidc_config(this, "client", this.module_name);
var stateObj = {
sub: consentInfo.idTokenContent.sub,
config: config
}
var credential_url = this.pk.util.httpsServerUrls['ISSUER_HOST'].href + this.module_name + '/credential';
potentialError = 'validating dpop';
await pk.token.validate_dpop(res.req.headers.dpop, content_module_state.sub, 'GET', credential_url);
var credInfo = {};
var credentialToken;
if (!params){
throw ('generateCredentialResponse called without parameters');
}
for (var key in params){
switch (key){
case 'credential_sub':
// credentialToken is added to credInfo by sts after signature
credentialToken = await generateCredentialInstance(res, this, params, consentInfo, content_module_state, stateObj, credential_url);
break;
case 'ancillary_claims':
try{
var dbInfo = pk.dbs[this.module_name];
var cred_metadata = await dbInfo.provider.getDocument(
dbInfo, 'cred_metadata', content_module_state.sub);
credInfo.ancillary_claims = cred_metadata.ancillaryClaims;
}
catch(err){
pk.util.log_error('generateCredentialResponse', 'ancillary_claims requested but cred_metadata not present');
}
break;
case 'wallet_proof':
var wallet_url = params[key];
var credTypeArr = consentInfo.idTokenContent.vc_constants.type;
var wallet_proof = await this.createWalletProof(credTypeArr[credTypeArr.length -1], wallet_url);
credInfo.wallet_proof = wallet_proof;
break;
case 'resources':
credInfo.resources = {};
var requestedResources = params[key];
var requestArr = requestedResources.split(/\s+/);
for (var i=0; i<requestArr.length; i++){
var request = requestArr[i];
credInfo.resources[request] = this.getResource(request);
}
break;
default:
pk.util.log_error('generateCredentialResponse', 'unknown request parameter: ' + key);
break;
}
}
this.pk.sts.submitCredentialResponse(res, this.module_name, credInfo, consentInfo, credentialToken, params.encryptTo);
}
catch (err){
var msg = 'Error in generateCredentialResponse - ' + potentialError;
pk.util.log_error(msg, err);
res.status(500).send(msg);
res.end();
}
// MUST only be visible within generateCredentialResponse
// assumes dpop and session token validated in requestCredentialResponse
async function generateCredentialInstance(res, context, params, consentInfo, content_module_state, stateObj, credential_url){
try{
var tokenClaims= {};
var potentialError = 'handling pairwise identifier validation';
if (!res.req.headers.credential_sub_dpop){
throw("credential_sub is missing credential_sub_dpop header -- the dpop showing holder controls the pairwise identifier");
}
await pk.token.validate_dpop(res.req.headers.credential_sub_dpop, params.credential_sub, 'GET', credential_url);
var vc = consentInfo.idTokenContent.vc_constants;
var claim_source = consentInfo.idTokenContent.claim_source;
var api_claimInfo = await context.api_entryPoints['get_claims'](stateObj);
var credentialSubject = api_claimInfo.credentialSubject;
var method = claim_source.credential_type;
if (method && method === 'realtime_encrypted'){
params.encryptTo = 'self';
}
vc.credentialSubject = credentialSubject;
tokenClaims.vc = vc;
var ancillaryClaims;
if (stateObj.config.ancillaryClaimHashes){
try{
var dbInfo = pk.dbs[context.module_name];
var cred_metadata = await dbInfo.provider.getDocument(
dbInfo, 'cred_metadata', content_module_state.sub);
ancillaryClaims = cred_metadata.ancillaryClaims;
}
catch(err){
pk.util.log_error('generateUserinfo', 'ancillaryClaims requested but cred_metadata not present');
}
}
if (ancillaryClaims){
var has_ancillary_claims = false;
var ancillaryClaimHashes = {
algorithm: "SHA256",
salt: await pk.simple_crypto.createB64Code(16)
}
for (var key in ancillaryClaims){
has_ancillary_claims = true;
if (!ancillaryClaims[key]){
continue;
}
ancillaryClaimHashes[key] = context.pk.simple_crypto.claimHash(
ancillaryClaimHashes.algorithm,
ancillaryClaimHashes.salt,
ancillaryClaims[key]);
}
if (has_ancillary_claims){
tokenClaims.ancillaryClaimHashes = ancillaryClaimHashes;
}
}
var credentialTTL = stateObj.config.credentialTTL;
if (!credentialTTL){
credentialTTL = 600;
}
var date = new Date();
var credentialToken = {
iss: context.pk.util.httpsServerUrls['ISSUER_HOST'].href + context.module_name,
// aud: content_module_state.client_id,
iat: Math.trunc(date.getTime() / 1000),
exp: Math.trunc(date.getTime() / 1000) + credentialTTL,
sub: params.credential_sub,
aud: content_module_state.client_id,
jti: pk.util.randomToURN()
};
for (var key in tokenClaims){
credentialToken[key] = tokenClaims[key];
}
return credentialToken;
}
catch (err){
var msg = 'Error in generateCredentialInstance - ' + potentialError;
pk.util.log_error(msg, err);
res.status(500).send(msg);
res.end();
}
}
}
async generateUserinfo(res, params, consentInfo, content_module_state){
var potentialError = 'initializing';
try {
this.pk.util.log_debug('--- CREDENTIAL ISSUER: GENERATE USERINFO ---');
this.pk.util.log_debug('Generating userInfo for ' + this.module_name);
this.pk.util.log_detail('sub of holder at issuer', content_module_state.sub);
var protocolPieces = {
authorization: res.req.headers.authorization,
dpop: res.req.headers.dpop,
content_module_state_sub: content_module_state.sub
}
for (var key in params){
protocolPieces[key] = params[key];
}
this.pk.util.log_protocol('USERINFO REQUEST', 'issuer', protocolPieces);
var userinfo_url = this.pk.util.httpsServerUrls['ISSUER_HOST'].href + this.module_name + '/userinfo';
potentialError = 'validating dpop';
await pk.token.validate_dpop(res.req.headers.dpop, content_module_state.sub, 'GET', userinfo_url);
var config = this.pk.util.get_oidc_config(this, "client", this.module_name);
var tokenClaims = {};
var encryptTo;
throw ('not implemented');
}
catch (err){
var msg = 'Error in generateUserInfo - ' + potentialError;
pk.util.log_error(msg, err);
res.status(500).send(msg);
res.end();
}
}
async invokeAuthUserAgent(req, res, params, encoded_sts_state_bundle, access_token){
pk.util.log_debug('--- CI: INVOKE AUTH USER AGENT ---');
pk.util.log_debug('OPERATING FOR MODULE:\'' + this.module_name);
var credential_uri = this.pk.util.httpsServerUrls['ISSUER_HOST'].href + this.module_name + '/credential';
var config = this.pk.util.get_oidc_config(this, "client", this.module_name);
var stateObj = {
client: this.module_name,
config: config,
params: params,
encoded_sts_state_bundle: encoded_sts_state_bundle,
sub: params.sub,
access_token: access_token,
credential_uri: credential_uri
}
return(stateObj);
}
// for use by the credential issuer
get_oidc_config(selector, value){
return(this.pk.util.get_oidc_config(this, selector, value));
}
get_credential_request_url(client){
var pwa = pk.util.httpsServerUrls['ISSUER_HOST'].href + pk.util.WALLET_ENDPOINT.substr(1);
var cred_issuer = pk.util.httpsServerUrls['ISSUER_HOST'].href + client;
var installPlusCredUrl = pwa + pk.util.createParameterString(
{req_cred: cred_issuer });
return installPlusCredUrl;
}
async get_credential_request_qrcode(client){
var installPlusCredUrl = this.get_credential_request_url(client);
return await pk.util.generateUrlQrCode(installPlusCredUrl);
}
async defaultApplication(req, res, client, handlebar){
var config = this.pk.util.get_oidc_config(this, 'client', client);
var viewPath = pk.path.join(process.cwd(), client, 'views');
var credential_request_url = this.get_credential_request_url(client);
var qr_code = await this.get_credential_request_qrcode(client);
res.set ({
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
'Pragma': 'no-cache',
});
res.render(pk.path.join(viewPath, handlebar), {
layout: 'main_bootstrap',
qr_code: qr_code,
credential_request_url: credential_request_url,
config: config
});
}
async createWalletProof(credential_type, wallet_url){
var date = new Date();
var wallet_proof = {
iss: this.pk.util.httpsServerUrls['ISSUER_HOST'].href + this.module_name,
aud: credential_type,
wallet: wallet_url,
nonce: await this.pk.simple_crypto.createNumericCode(9),
iat: Math.trunc(date.getTime() / 1000),
exp: Math.trunc(date.getTime() / 1000) + 30
}
var signingKey = pk.key_management.contentModuleSigningKeys[this.module_name];
// pk.util.log_detail('idToken', idToken);
var walletProofString = JSON.stringify(wallet_proof);
var jws_compact = await pk.claimer_crypto.JWS.createSign(
{ format: 'compact', fields: { typ: 'JWT' } },
signingKey).update(walletProofString).final();
return jws_compact;
}
}
function proxyIntrinsicallyGrantedConsent(scopeInfo, content_module_state){
var consentInfo = {
claims: {
id_token: {},
userinfo: {}
},
scopeArray: []
}
var scopeArrayLength = scopeInfo.scopeArray.length;
for (var count=0; count < scopeArrayLength; count++){
consentInfo.scopeArray[count] = scopeInfo.scopeArray[count];
}
if (!content_module_state.scope){
throw("Error: scope is mandatory in proxyIntrinsicallyGrantedConsent");
}
var issuer_scopes = content_module_state.scope.split(" ");
for (var c=0; c < issuer_scopes.length; c++){
var scope = issuer_scopes[c];
if (scope && consentInfo.scopeArray.indexOf(scope) < 0){
consentInfo.scopeArray[count++] = scope;
}
}
for (var key in scopeInfo.claims.id_token){
consentInfo.claims.id_token[key] = scopeInfo.claims.id_token[key];
}
for (var key in scopeInfo.claims.userinfo){
consentInfo.claims.userinfo[key] = scopeInfo.claims.userinfo[key];
}
return consentInfo;
}
function claimEncodedInCard(claim_value){
return claim_value.length > 2048;
}
function process_display_render(path){
var childProcess = require('child_process');
function runScript(scriptPath, callback) {
// keep track of whether callback has been invoked to prevent multiple invocations
var invoked = false;
var process = childProcess.fork(scriptPath);
// listen for errors as they may prevent the exit event from firing
process.on('error', function (err) {
if (invoked) return;
invoked = true;
callback(err);
});
// execute the callback once the process has finished running
process.on('exit', function (code) {
if (invoked) return;
invoked = true;
var err = code === 0 ? null : new Error('exit code ' + code);
callback(err);
md.js
});
}
var command = 'browserify ' + path + ' 1 > ' + path + '.out';
// Now we can run a script and invoke a callback when complete, e.g.
runScript(command, function (err) {
if (err) throw err;
console.log('finished running some-script.js');
});
}