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
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": "pnpm@10.8.0",
"dependencies": {
Expand Down
118 changes: 118 additions & 0 deletions src/shc/directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { DirectoryJSON, Issuer, IssuerCrl, IssuerKey } from './types'

/**
* Directory is a lightweight representation of issuer metadata used by
* SMART Health Cards code paths. It contains a collection of issuer info
* objects including the issuer identifier, known JWK keys and optionally
* Certificate Revocation Lists (CRLs).
*
* @public
* @group SHC
* @category Lower-Level API
*/
export class Directory {
/**
* Create a new Directory instance from a list of issuer info objects.
*
* @param issuerInfo - Array of issuer entries (see {@link Issuer})
*/
constructor(private issuerInfo: Issuer[]) {}

/**
* Return the internal issuer info array.
*
* @returns Array of issuer info objects
*/
getIssuerInfo(): Issuer[] {
return this.issuerInfo
}

/**
* Build a Directory from a parsed JSON object matching the published
* directory schema.
*
* This method is defensive: if `issuer.iss` is missing or not a string it
* will be coerced to an empty string; if `keys` or `crls` are not arrays
* they will be treated as empty arrays.
*
* @param directoryJson - The JSON object to convert into a Directory
* @returns A new {@link Directory} instance
* @example
* const directory = Directory.fromJSON(parsedJson)
*/
static fromJSON(directoryJson: DirectoryJSON): Directory {
const data: Issuer[] = directoryJson.issuerInfo.map(({ issuer, keys, crls }) => {
const iss = typeof issuer?.iss === 'string' ? issuer.iss : ''
const validKeys = Array.isArray(keys) ? keys : []
const validCrls = Array.isArray(crls) ? crls : []
return {
iss,
keys: validKeys,
crls: validCrls,
}
})
return new Directory(data)
}

/**
* Create a Directory by fetching issuer metadata (JWKS) and CRLs from the
* provided issuer base URLs.
*
* For each issuer URL the method attempts to fetch `/.well-known/jwks.json`
* and will then attempt to fetch CRLs for each key at
* `/.well-known/crl/{kid}.json`. Failures to fetch a JWKS will skip that
* issuer; failures to fetch a CRL for an individual key will skip that key's
* CRL but keep the key. Errors are logged via `console.debug` and
* unexpected exceptions are caught and logged with `console.error`.
*
* @param issUrls - Array of issuer base URLs to fetch (e.g. `https://example.com/issuer`)
* @returns A {@link Directory} containing any successfully fetched issuer info
* @example
* const directory = await Directory.fromURLs(['https://example.com/issuer'])
*/
static async fromURLs(issUrls: string[]): Promise<Directory> {
const directoryJson: DirectoryJSON = {
issuerInfo: [],
}

try {
for (const issUrl of issUrls) {
const issuerInfo = {
issuer: {
iss: issUrl,
},
keys: [] as IssuerKey[],
crls: [] as IssuerCrl[],
}

const jwksUrl = `${issUrl}/.well-known/jwks.json`
const jwksResponse = await fetch(jwksUrl)
if (!jwksResponse.ok) {
const errorMessage = `Failed to fetch jwks at ${jwksUrl} with status ${jwksResponse.status}, skipping issuer.`
console.debug(errorMessage)
continue
}

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

directoryJson.issuerInfo.push(issuerInfo)
}
} catch (error) {
console.error('Error creating Directory:', error)
}

return Directory.fromJSON(directoryJson)
}
}
1 change: 1 addition & 0 deletions src/shc/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SMART Health Cards barrel export

export * from './directory.js'
export * from './errors.js'
export * from './fhir/index.js'
export * from './issuer.js'
Expand Down
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,
issuerDirectory: config.issuerDirectory ?? 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.issuerDirectory) {
issuerInfo = this.config.issuerDirectory.getIssuerInfo()
}
return new SHC(jws, originalBundle, issuerInfo)
} catch (error) {
if (error instanceof SHCError) {
throw error
Expand Down
14 changes: 12 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,15 @@ export class SHC {
return this.originalBundle
}

/**
* Return the issuer metadata associated with this health card.
*
* @returns Array of issuer objects (may be empty)
*/
getIssuerInfo(): Issuer[] {
return this.issuerInfo
}

/**
* Generate QR code data URLs from the health card.
*
Expand Down
78 changes: 78 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,14 @@ export interface SHCReaderConfigParams {
* @defaultValue `true`
*/
verifyExpiration?: boolean

/**
* Optional pre-fetched `Directory` instance containing issuer metadata
* (JWKS keys and optional CRLs). Pass `null` to explicitly indicate
* no directory is available.
* @defaultValue `null`
*/
issuerDirectory?: Directory | null
}

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

/**
* Minimal JWK descriptor used in the public Directory representation.
*
* @public
* @group SHC
* @category Types
*/
export interface IssuerKey {
/** Key type (e.g. 'EC' or 'RSA'). */
kty: string
/** Key ID used to identify the key in JWKS responses. */
kid: string
}

/**
* Representation of a Certificate Revocation List (CRL) entry used by the
* directory to mark revoked resource IDs for a given key.
*
* @public
* @group SHC
* @category Types
*/
export interface IssuerCrl {
/** The `kid` of the key this CRL pertains to. */
kid: string
/** Revocation method identifier (e.g. 'rid'). */
method: string
/** Monotonic counter for CRL updates. */
ctr: number
/** List of revoked resource ids (rids). */
rids: string[]
}

/**
* Public issuer metadata aggregated by the Directory.
*
* @public
* @group SHC
* @category Types
*/
export interface Issuer {
/** Issuer base URL (the `iss` claim value). */
iss: string
/** Array of known JWK descriptors for the issuer. */
keys: IssuerKey[]
/** Optional array of CRL entries for revoked resource ids. */
crls?: IssuerCrl[]
}

/**
* JSON shape for a published directory file containing issuer metadata.
*
* @public
* @group SHC
* @category Types
*/
export interface DirectoryJSON {
issuerInfo: {
issuer: {
/** Issuer base URL */
iss: string
}
/** Array of JWK descriptors returned from the issuer's JWKS endpoint. */
keys: IssuerKey[]
/** Optional CRL entries published alongside the directory. */
crls?: IssuerCrl[]
}[]
}
Loading