Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions scripts/epic-key-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Verifies the private key and JWKS are a matching pair,
* and checks the JWT assertion that would be sent to Epic.
*/
import { createSign, createVerify, createPublicKey } from 'node:crypto';
import { readFileSync } from 'node:fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const privateKeyPath = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem');
const jwksPath = path.join(__dirname, '..', 'epic-keys', 'jwks.json');

console.log('\n\x1b[1mEpic Key Pair Diagnostic\x1b[0m\n');

// ── 1. Load private key ────────────────────────────────────────────────────
let privPem;
try {
privPem = readFileSync(privateKeyPath, 'utf8');
console.log('✓ Private key file loaded');
} catch(e) {
console.log('✗ Cannot read private key:', e.message);
process.exit(1);
}

// ── 2. Derive public key from private key ─────────────────────────────────
let derivedPub;
try {
const privKeyObj = createPublicKey({ key: privPem, format: 'pem' });
derivedPub = privKeyObj.export({ type: 'spki', format: 'pem' });
console.log('✓ Public key derived from private key');
} catch(e) {
console.log('✗ Private key is invalid / unreadable:', e.message);
process.exit(1);
}

// ── 3. Load JWKS and extract n+e ──────────────────────────────────────────
let jwks;
try {
jwks = JSON.parse(readFileSync(jwksPath, 'utf8'));
console.log('✓ JWKS file loaded');
const k = jwks.keys[0];
console.log(` kid : ${k.kid}`);
console.log(` alg : ${k.alg}`);
console.log(` use : ${k.use}`);
console.log(` n : ${k.n.substring(0,32)}…`);
} catch(e) {
console.log('✗ Cannot read JWKS:', e.message);
process.exit(1);
}

// ── 4. Verify keypair matches by sign+verify ──────────────────────────────
try {
const testData = 'transtrack-epic-keypair-check';
const signer = createSign('RSA-SHA384');
signer.update(testData);
const sig = signer.sign(privPem);

const verifier = createVerify('RSA-SHA384');
verifier.update(testData);
const ok = verifier.verify(derivedPub, sig);

if (ok) {
console.log('✓ Private key signs correctly — keypair is internally consistent');
} else {
console.log('✗ Signature verification failed — key may be corrupted');
}
} catch(e) {
console.log('✗ Sign/verify error:', e.message);
}

// ── 5. Reconstruct JWK n value from private key and compare ──────────────
try {
const pubKey = createPublicKey({ key: privPem, format: 'pem' });
const jwkFromPrivate = pubKey.export({ format: 'jwk' });
const nFromPrivate = jwkFromPrivate.n;
const nFromJwks = jwks.keys[0].n;

if (nFromPrivate === nFromJwks) {
console.log('✓ JWKS n value MATCHES the private key — correct keypair registered');
} else {
console.log('✗ JWKS n value DOES NOT MATCH the private key');
console.log(' This is the cause of invalid_client: Epic has a different public key.');
console.log(' You need to either:');
console.log(' (a) Re-register the JWKS in your Epic app with this file\'s public key, OR');
console.log(' (b) Replace epic-keys/ with the keypair that IS registered in Epic');
console.log(`\n n from private key : ${nFromPrivate.substring(0,48)}…`);
console.log(` n from jwks.json : ${nFromJwks.substring(0,48)}…`);
}
} catch(e) {
console.log('✗ JWK comparison error:', e.message);
}

// ── 6. Check kid matches ──────────────────────────────────────────────────
const kidInJwks = jwks.keys[0]?.kid;
const kidInCode = 'transtrack-epic-1';
console.log(`\n kid in jwks.json : ${kidInJwks}`);
console.log(` kid used in JWT : ${kidInCode}`);
if (kidInJwks === kidInCode) {
console.log('✓ kid values match');
} else {
console.log('✗ kid MISMATCH — Epic will reject the JWT because kid does not match any registered key');
}

// ── 7. Print the full public key for copy-paste into Epic ─────────────────
console.log('\n\x1b[1mPublic key (for pasting into Epic app registration if needed):\x1b[0m');
console.log(derivedPub);
172 changes: 172 additions & 0 deletions scripts/epic-sandbox-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Epic sandbox diagnostic script.
* Checks: token auth, granted scopes, and a live patient bundle fetch.
* Usage: node scripts/epic-sandbox-check.mjs
*/
import { createRequire } from 'module';
import path from 'path';
import { fileURLToPath } from 'url';

const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const { createEpicClientFromKeyFile, DEFAULT_SCOPES } = require('../server/src/integrations/epic/client.js');

const CLIENT_ID = 'a8634931-c997-4516-90cd-21ec3a27813e';
const KEY_FILE = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem');
const TEST_PATIENT = 'erXuFYUfucBZaryVksYEcMg3'; // Epic sandbox: Camila Maria Lopez

const REQUESTED_SCOPES = DEFAULT_SCOPES.split(' ');

function tick(label) { process.stdout.write(`\n \x1b[32m✓\x1b[0m ${label}`); }
function fail(label) { process.stdout.write(`\n \x1b[31m✗\x1b[0m ${label}`); }
function info(label) { process.stdout.write(`\n \x1b[36m·\x1b[0m ${label}`); }
function section(title) { console.log(`\n\x1b[1m\x1b[34m── ${title} ──\x1b[0m`); }

async function run() {
console.log('\n\x1b[1mTransTrack × Epic Sandbox Diagnostic\x1b[0m');
console.log(` Client ID : ${CLIENT_ID}`);
console.log(` Key file : ${KEY_FILE}`);

// ── 1. TOKEN EXCHANGE ──────────────────────────────────────────────────────
section('1 · Token Exchange (SMART Backend Services)');
let client;
let tok;
try {
client = createEpicClientFromKeyFile({
clientId: CLIENT_ID,
privateKeyFile: KEY_FILE,
});
tok = await client.getAccessToken();
tick(`Access token obtained (expires in ~${Math.round((tok.expiresAt - Date.now()) / 1000)}s)`);
tick(`Token type: ${tok.tokenType}`);
} catch (err) {
fail(`Token exchange FAILED: ${err.message}`);
process.exit(1);
}

// ── 2. SCOPE CHECK ─────────────────────────────────────────────────────────
section('2 · Scope Check');
const grantedScopes = (tok.scope || '').split(/\s+/).filter(Boolean);
info(`Scopes granted by Epic (${grantedScopes.length}):`);
for (const s of grantedScopes) {
process.stdout.write(`\n \x1b[32m${s}\x1b[0m`);
}

console.log('\n');
info(`Scopes requested by TransTrack code (${REQUESTED_SCOPES.length}):`);
const missing = [];
for (const s of REQUESTED_SCOPES) {
if (grantedScopes.includes(s)) {
process.stdout.write(`\n \x1b[32m✓ ${s}\x1b[0m`);
} else {
process.stdout.write(`\n \x1b[31m✗ ${s} ← NOT GRANTED\x1b[0m`);
missing.push(s);
}
}
console.log('\n');
if (missing.length === 0) {
tick('All requested scopes are granted');
} else {
fail(`${missing.length} requested scope(s) NOT granted — these resources will fail at import`);
}

// ── 3. FHIR METADATA ───────────────────────────────────────────────────────
section('3 · FHIR Server Metadata');
try {
const meta = await client.fhirGet('metadata');
tick(`FHIR version : ${meta.fhirVersion}`);
tick(`Software : ${meta.software?.name || 'unknown'} ${meta.software?.version || ''}`);
const supportedResources = (meta.rest?.[0]?.resource || []).map(r => r.type);
tick(`Resources supported: ${supportedResources.length}`);
const ourResources = ['Patient','Observation','Condition','MedicationRequest','AllergyIntolerance'];
for (const r of ourResources) {
if (supportedResources.includes(r)) {
process.stdout.write(`\n \x1b[32m✓ ${r}\x1b[0m`);
} else {
process.stdout.write(`\n \x1b[31m✗ ${r} ← not in CapabilityStatement\x1b[0m`);
}
}
console.log('');
} catch (err) {
fail(`Metadata fetch failed: ${err.message}`);
}

// ── 4. LIVE PATIENT BUNDLE FETCH ───────────────────────────────────────────
section(`4 · Patient Bundle Fetch (Camila Lopez · ${TEST_PATIENT})`);
let bundle;
try {
bundle = await client.fetchPatientBundle(TEST_PATIENT);
tick(`Patient : ${bundle.patient?.name?.[0]?.given?.join(' ')} ${bundle.patient?.name?.[0]?.family}`);
tick(`DOB : ${bundle.patient?.birthDate}`);
tick(`Gender : ${bundle.patient?.gender}`);
tick(`MRN : ${bundle.patient?.identifier?.find(i => i.type?.coding?.[0]?.code === 'MR')?.value || bundle.patient?.identifier?.[0]?.value || 'none'}`);
console.log('');
info(`Observations (labs) : ${bundle.observations.length}`);
info(`Conditions (problem list) : ${bundle.conditions.length}`);
info(`Medication requests : ${bundle.medicationRequests.length}`);
info(`Allergy intolerances : ${bundle.allergies.length}`);
info(`Scope granted : ${bundle.scopeGranted}`);
} catch (err) {
fail(`Patient bundle fetch FAILED: ${err.message}`);
process.exit(1);
}

// ── 5. NATIVE TABLE PREVIEW ────────────────────────────────────────────────
section('5 · Native Table Materialisation Preview (dry run — no DB)');

// Observations → lab_results
console.log('\n \x1b[1mObservations → lab_results\x1b[0m');
let labCount = 0;
for (const obs of bundle.observations.slice(0, 5)) {
const coding = obs.code?.coding?.[0];
const value = obs.valueQuantity
? `${obs.valueQuantity.value} ${obs.valueQuantity.unit || ''}`.trim()
: obs.valueString ?? obs.valueCodeableConcept?.text ?? null;
if (coding && value != null) {
info(`${coding.display || coding.code} → ${value} (${obs.effectiveDateTime?.substring(0,10) || '?'})`);
labCount++;
}
}
if (bundle.observations.length > 5) info(`… and ${bundle.observations.length - 5} more`);

// Conditions → patient_conditions
console.log('\n \x1b[1mConditions → patient_conditions\x1b[0m');
for (const c of bundle.conditions.slice(0, 5)) {
const display = c.code?.coding?.[0]?.display || c.code?.text || 'unknown';
const status = c.clinicalStatus?.coding?.[0]?.code || '?';
info(`${display} [${status}]`);
}
if (bundle.conditions.length > 5) info(`… and ${bundle.conditions.length - 5} more`);

// MedicationRequests → patient_medications
console.log('\n \x1b[1mMedicationRequests → patient_medications\x1b[0m');
for (const m of bundle.medicationRequests.slice(0, 5)) {
const name = m.medicationCodeableConcept?.coding?.[0]?.display || m.medicationCodeableConcept?.text || 'unknown';
const status = m.status || '?';
info(`${name} [${status}]`);
}
if (bundle.medicationRequests.length > 5) info(`… and ${bundle.medicationRequests.length - 5} more`);

// Allergies → patient_allergies
console.log('\n \x1b[1mAllergyIntolerances → patient_allergies\x1b[0m');
for (const a of bundle.allergies.slice(0, 5)) {
const display = a.code?.coding?.[0]?.display || a.code?.text || 'unknown';
const criticality = a.criticality || '?';
info(`${display} [criticality: ${criticality}]`);
}
if (bundle.allergies.length > 5) info(`… and ${bundle.allergies.length - 5} more`);

// ── SUMMARY ────────────────────────────────────────────────────────────────
section('Summary');
tick('Token exchange OK');
missing.length === 0 ? tick('All scopes granted OK') : fail(`Scopes missing: ${missing.join(', ')}`);
tick(`Patient fetch OK (${bundle.observations.length} obs, ${bundle.conditions.length} cond, ${bundle.medicationRequests.length} meds, ${bundle.allergies.length} allergies)`);
tick('Native table mapping Ready (run importPatientFromEpic to write to DB)');
console.log('\n');
}

run().catch(err => {
console.error('\nFatal:', err.message);
process.exit(1);
});
Loading
Loading