Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"url": "https://github.com/vintasoftware/kill-the-clipboard/issues"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"packageManager": "[email protected]",
"dependencies": {
Expand Down
60 changes: 60 additions & 0 deletions src/shc/directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { DirectoryJSON, Issuer } from './types'

export class Directory {
constructor(private issuerInfo: Issuer[]) {}

getIssuerInfo(): Issuer[] {
return this.issuerInfo
}

static fromJSON(dirJson: DirectoryJSON): Directory {
const data: Issuer[] = dirJson.issuerInfo.map(({ issuer: { iss }, keys, crls }) => {
return { iss, keys, crls } as Issuer
})
return new Directory(data)
}

static async fromURLs(issUrls: string[]): Promise<Directory> {
const issuersInfo: Issuer[] = []

try {
for (const issUrl of issUrls) {
const issuer: Issuer = {
iss: issUrl,
keys: [],
crls: [],
}

const crls = []
const jwksUrl = `${issUrl}/.well-known/jwks.json`
const jwksResponse = await fetch(jwksUrl)
if (!jwksResponse.ok) {
const errorData = await jwksResponse.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(errorData.error || `Failed to fetch jwks at ${jwksUrl}`)
}

const { keys: issKeys } = await jwksResponse.json()
for (const key of issKeys) {
const crlUrl = `${issUrl}/.well-known/crl/${key.kid}.json`
const crlResponse = await fetch(crlUrl)
if (!crlResponse.ok) {
console.debug(
`Failed to fetch crl at ${crlUrl} with status ${crlResponse.status}, skipping.`
)
continue
}
const crl = await crlResponse.json()
if (crl) crls.push(crl)
}

issuer.keys = issKeys
issuer.crls = crls
issuersInfo.push(issuer)
}
} catch (error) {
console.error('Error creating Directory:', error)
}

return new Directory(issuersInfo)
}
}
9 changes: 7 additions & 2 deletions src/shc/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { JWSProcessor } from './jws/jws-processor.js'
import { SHC } from './shc.js'
import type {
FHIRBundle,
Issuer,
SHCConfig,
SHCConfigParams,
SHCJWT,
Expand Down Expand Up @@ -79,9 +80,13 @@ export class SHCIssuer {
* });
* ```
*/
async issue(fhirBundle: FHIRBundle, config: VerifiableCredentialParams = {}): Promise<SHC> {
async issue(
fhirBundle: FHIRBundle,
config: VerifiableCredentialParams = {},
issuerInfo: Issuer[] = []
): Promise<SHC> {
const jws = await this.createJWS(fhirBundle, config)
return new SHC(jws, fhirBundle)
return new SHC(jws, fhirBundle, issuerInfo)
}

/**
Expand Down
14 changes: 12 additions & 2 deletions src/shc/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { FHIRBundleProcessor } from './fhir/bundle-processor.js'
import { JWSProcessor } from './jws/jws-processor.js'
import { QRCodeGenerator } from './qr/qr-code-generator.js'
import { SHC } from './shc.js'
import type { SHCReaderConfig, SHCReaderConfigParams, VerifiableCredential } from './types.js'
import type {
Issuer,
SHCReaderConfig,
SHCReaderConfigParams,
VerifiableCredential,
} from './types.js'
import { VerifiableCredentialProcessor } from './vc.js'

/**
Expand Down Expand Up @@ -50,6 +55,7 @@ export class SHCReader {
enableQROptimization: config.enableQROptimization ?? true,
strictReferences: config.strictReferences ?? true,
verifyExpiration: config.verifyExpiration ?? true,
directory: config.directory ?? null,
}

this.bundleProcessor = new FHIRBundleProcessor()
Expand Down Expand Up @@ -147,7 +153,11 @@ export class SHCReader {
this.vcProcessor.validate(vc)

// Step 4: Return the original FHIR Bundle
return new SHC(jws, originalBundle)
let issuerInfo: Issuer[] = []
if (this.config.directory) {
issuerInfo = this.config.directory.getIssuerInfo()
}
return new SHC(jws, originalBundle, issuerInfo)
} catch (error) {
if (error instanceof SHCError) {
throw error
Expand Down
9 changes: 7 additions & 2 deletions src/shc/shc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { FHIRBundleProcessor } from './fhir/bundle-processor.js'
import { QRCodeGenerator } from './qr/qr-code-generator.js'
import type { FHIRBundle, QRCodeConfigParams } from './types.js'
import type { FHIRBundle, Issuer, QRCodeConfigParams } from './types.js'

/**
* Represents an issued SMART Health Card with various output formats.
Expand All @@ -15,7 +15,8 @@ import type { FHIRBundle, QRCodeConfigParams } from './types.js'
export class SHC {
constructor(
private readonly jws: string,
private readonly originalBundle: FHIRBundle
private readonly originalBundle: FHIRBundle,
private readonly issuerInfo: Issuer[] = []
) {}

/**
Expand All @@ -36,6 +37,10 @@ export class SHC {
return this.originalBundle
}

getIssuerInfo(): Issuer[] {
return this.issuerInfo
}

/**
* Generate QR code data URLs from the health card.
*
Expand Down
31 changes: 31 additions & 0 deletions src/shc/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Types and processors for SMART Health Cards
import type { Bundle } from '@medplum/fhirtypes'
import type { Directory } from './directory'

/**
* FHIR R4 Bundle type re-exported from @medplum/fhirtypes for convenience.
Expand Down Expand Up @@ -141,6 +142,8 @@ export interface SHCReaderConfigParams {
* @defaultValue `true`
*/
verifyExpiration?: boolean

directory?: Directory | null
}

/**
Expand Down Expand Up @@ -288,3 +291,31 @@ export interface QRCodeConfigParams {
* @category Configuration
*/
export type QRCodeConfig = Required<QRCodeConfigParams>

export interface IssuerKey {
kty: string
kid: string
}

export interface IssuerCrl {
kid: string
method: string
ctr: number
rids: string[]
}

export interface Issuer {
iss: string
keys: IssuerKey[]
crls?: IssuerCrl[]
}

export interface DirectoryJSON {
issuerInfo: {
issuer: {
iss: string
}
keys: IssuerKey[]
crls?: IssuerCrl[]
}[]
}
Loading