Skip to content

Commit

Permalink
Ihe projectathon (#64)
Browse files Browse the repository at this point in the history
* Fix up viewer prompt

* Fix username display

* Use custom version of retrieve function

* Revert viewer changes

* Add fields for create config w/vs

* Fix urls

* Update BT

* Add buffer lib for shl decoding

* Add local shlClient with json file support

* Fix url dropdown overflow

* Fix typescript config warning

* Add shl api fields (management token and userId) to bridge new auth

* Fix json shl content type check

* Replace minified shlClient.js with shlClient.ts
  • Loading branch information
daniellrgn authored Feb 19, 2025
1 parent c30f7b6 commit 4430c96
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 52 deletions.
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

0 comments on commit 4430c96

Please sign in to comment.