UNPKG

oidc-lib

Version:

A library for creating OIDC Service Providers

722 lines (593 loc) 23.9 kB
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'); }); }