Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ihe projectathon #64

Merged
merged 14 commits into from
Feb 19, 2025
46 changes: 34 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-static": "^2.0.0",
"@sveltejs/kit": "^1.5.0",
"base64url": "^3.0.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"svelte": "^3.55.1",
Expand All @@ -30,8 +31,8 @@
"@types/node": "^20.8.7",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.5.0",
"base64url": "^3.0.1",
"bootstrap": "^5.2.3",
"buffer": "^6.0.3",
"fhirclient": "^2.5.2",
"jose": "^4.11.4",
"oidc-client-ts": "^3.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/app/FetchUrl.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
color: rgb(50, 50, 50);"/>
</div>
</DropdownToggle>
<DropdownMenu style="width:100%">
<DropdownMenu style="max-height: 400px; width:100%; overflow:scroll">
{#if Object.keys(PATIENT_IPS).length > 0}
<DropdownItem header>Actual Patient Data (permitted for use)</DropdownItem>
{#each Object.entries(PATIENT_IPS) as [title, url]}
Expand Down
17 changes: 10 additions & 7 deletions src/lib/utils/managementClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { AuthService } from './AuthService';

interface ConfigForServer extends Pick<SHLAdminParams, 'passcode' | 'exp' | 'label'> {
userId?: string;
patientId?: string;
pin?: string;
patientIdentifierSystem?: string;
}

export interface SHLAdminParams {
Expand Down Expand Up @@ -64,7 +67,7 @@ export class SHLClient {
const res = await fetch(`${API_BASE}/shl`, {
method: 'POST',
headers: {
'content-type': 'application/json'
"Content-Type": 'application/json',
},
body: JSON.stringify(config)
});
Expand All @@ -80,7 +83,7 @@ export class SHLClient {
const res = await fetch(`${API_BASE}/shl/${shl.id}`, {
method: 'DELETE',
headers: {
authorization: `Bearer ${shl.managementToken}`
"Authorization": `Bearer ${shl.managementToken}`
}
});
const deleted = await res.json();
Expand All @@ -92,7 +95,7 @@ export class SHLClient {
method: 'PUT',
body: JSON.stringify({ passcode: shl.passcode, exp: shl.exp, label: shl.label }),
headers: {
authorization: `Bearer ${shl.managementToken}`
"Authorization": `Bearer ${shl.managementToken}`
}
});
const updatedShl = await res.json();
Expand Down Expand Up @@ -135,7 +138,7 @@ export class SHLClient {
const res = await fetch(`${API_BASE}/shl/${shl.id}/reactivate`, {
method: 'PUT',
headers: {
authorization: `Bearer ${shl.managementToken}`
"Authorization": `Bearer ${shl.managementToken}`
}
});
const reactivated = await res.json();
Expand Down Expand Up @@ -163,8 +166,8 @@ export class SHLClient {
const res = await fetch(`${API_BASE}/shl/${shl.id}/file`, {
method: 'POST',
headers: {
'content-type': contentType,
authorization: `Bearer ${shl.managementToken}`
"Content-Type": contentType,
"Authorization": `Bearer ${shl.managementToken}`
},
body: contentEncrypted
});
Expand All @@ -176,7 +179,7 @@ export class SHLClient {
const res = await fetch(`${API_BASE}/shl/${shl.id}/file`, {
method: 'DELETE',
headers: {
authorization: `Bearer ${shl.managementToken}`
"Authorization": `Bearer ${shl.managementToken}`
},
body: contentHash
});
Expand Down
4 changes: 2 additions & 2 deletions src/lib/utils/retreiveIPS.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function retrieve(){
let passcode;
const needPasscode = shlClient.flag({ shl }).includes('P');
if (needPasscode) {
passcode = prompt("Enter passcode for SMART Health Link");
passcode = prompt("Patient Summary Viewer\n----------------------\nEnter passcode for SMART Health Link\nIf no passcode was set, just click \"OK\"");
}
$('#status').text("Retrieving contents...");
let retrieveResult;
Expand All @@ -46,7 +46,7 @@ async function retrieve(){
} else if (retrieveResult.status === 401) {
// Failed the password requirement
while (retrieveResult.status === 401) {
passcode = prompt(`Enter passcode for SMART Health Link ${retrieveResult.error.remainingAttempts !== undefined ? "\nAttempts remaining: "+retrieveResult.error.remainingAttempts : ""}`);
passcode = prompt(`Patient Summary Viewer\n----------------------\nEnter passcode for SMART Health Link\nIf no passcode was set, just click \"OK\"${retrieveResult.error.remainingAttempts !== undefined ? "\nAttempts remaining: "+retrieveResult.error.remainingAttempts : ""}`);
try {
retrieveResult = await shlClient.retrieve({
shl,
Expand Down
9 changes: 0 additions & 9 deletions src/lib/utils/shlClient.js

This file was deleted.

7 changes: 0 additions & 7 deletions src/lib/utils/shlClient.js.map

This file was deleted.

151 changes: 151 additions & 0 deletions src/lib/utils/shlClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import base64url from 'base64url';
import * as jose from 'jose';
import { Buffer } from 'buffer';

export interface SHLinkConnectRequest {
shl: string;
recipient: string;
passcode?: string;
}

export interface SHLinkConnectResponse {
state: string;
shcs: string[];
jsons: string[];
}

export interface SHLClientStateDecoded {
url: string;
key: string;
recipient: string;
passcode?: string;
}

export interface SHLDecoded {
url: string;
key: string;
flag?: string;
label?: string;
}

export interface SHLManifestFile {
files: {
contentType: string;
location: string;
embedded?: string;
}[];
}

export function randomStringWithEntropy(entropy: number) {
const b = new Uint8Array(entropy);
crypto.getRandomValues(b);
return base64url.encode(b.buffer as Buffer);
}

export function decodeBase64urlToJson<T>(s: string): T {
return JSON.parse(Buffer.from(s, 'base64').toString()) as T;
}

export function decodeToJson<T>(s: Uint8Array): T {
return JSON.parse(new TextDecoder().decode(s)) as T;
}

export interface Manifest {
file: { contentType: string; location: string }[];
}

export function flag(config: { shl: string }) {
const shlBody = config.shl.split(/^(?:.+:\/.+#)?shlink:\//)[1];
const parsedShl: SHLDecoded = decodeBase64urlToJson(shlBody);
return parsedShl?.flag;
}

function needPasscode(config: { shl: string }) {
const shlBody = config.shl.split(/^(?:.+:\/.+#)?shlink:\//)[1];
const parsedShl: SHLDecoded = decodeBase64urlToJson(shlBody);
if (parsedShl.flag?.includes('P')) {
return true;
}

return false;
}

export function id(config: { shl: string }) {
const shlBody = config.shl.split(/^(?:.+:\/.+#)?shlink:\//)[1];
const parsedShl: SHLDecoded = decodeBase64urlToJson(shlBody);
return new URL(parsedShl?.url).href.split("/").pop();
}

export async function retrieve(configIncoming: SHLinkConnectRequest | {state: string}) {
const config: SHLinkConnectRequest = configIncoming["state"] ? JSON.parse(base64url.decode(configIncoming["state"])) : configIncoming
const shlBody = config.shl.split(/^(?:.+:\/.+#)?shlink:\//)[1];
const parsedShl: SHLDecoded = decodeBase64urlToJson(shlBody);
const manifestResponse = await fetch(parsedShl.url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
passcode: config.passcode,
recipient: config.recipient,
}),
});
let isJson = false;
let manifestResponseContent;
manifestResponseContent = await manifestResponse.text();
try {
manifestResponseContent = JSON.parse(manifestResponseContent);
isJson = true;
} catch (error) {
console.warn("Manifest did not return JSON object");
}

if (!manifestResponse.ok || !isJson) {
return {
status: manifestResponse.status,
error: (manifestResponseContent ?? "")
};
} else {
const decryptionKey = Buffer.from(parsedShl.key, 'base64');
const allFiles = (manifestResponseContent as SHLManifestFile).files
.filter((f) => f.contentType === 'application/smart-health-card')
.map(async (f) => {
if (f.embedded !== undefined) {
return f.embedded
} else {
return fetch(f.location).then((f) => f.text())
}
});

const allFilesDecrypted = allFiles.map(async (f) => {
const decrypted = await jose.compactDecrypt(await f, decryptionKey);
const decoded = new TextDecoder().decode(decrypted.plaintext);
return decoded;
});

const shcs = (await Promise.all(allFilesDecrypted)).flatMap((f) => JSON.parse(f)['verifiableCredential'] as string);

const jsonFiles = (manifestResponseContent as SHLManifestFile).files
.filter((f) => f.contentType === 'application/fhir+json')
.map(async (f) => {
if (f.embedded !== undefined) {
return f.embedded
} else {
return fetch(f.location).then((f) => f.text())
}
});

const allJsonFilesDecrypted = jsonFiles.map(async (f) => {
const decrypted = await jose.compactDecrypt(await f, decryptionKey);
const decoded = new TextDecoder().decode(decrypted.plaintext);
return decoded;
});

const jsons = (await Promise.all(allJsonFilesDecrypted)).flatMap((f) => JSON.parse(f));


const result: SHLinkConnectResponse = { shcs, jsons, state: btoa(JSON.stringify(config))};

return result;
}
}
2 changes: 1 addition & 1 deletion src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
<Dropdown nav inNavbar class="navbar-dropdown" size="sm" direction="down">
<DropdownToggle color="primary" nav caret><Icon name="person-circle"/> Account</DropdownToggle>
<DropdownMenu end style="max-height: 500px; overflow:auto">
<DropdownItem header>Welcome, {profile.given_name}</DropdownItem>
<DropdownItem header>Welcome, {profile.given_name ?? profile.preferred_username}</DropdownItem>
<DropdownItem
on:click={() => {
authService.logout();
Expand Down
Loading