diff --git a/package.json b/package.json index f37066c..9a4c058 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/shc/directory.ts b/src/shc/directory.ts new file mode 100644 index 0000000..8853a8d --- /dev/null +++ b/src/shc/directory.ts @@ -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 { + 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) + } +} diff --git a/src/shc/index.ts b/src/shc/index.ts index 7f9e915..d51ae08 100644 --- a/src/shc/index.ts +++ b/src/shc/index.ts @@ -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' diff --git a/src/shc/issuer.ts b/src/shc/issuer.ts index 3d3da8a..2c1a1ec 100644 --- a/src/shc/issuer.ts +++ b/src/shc/issuer.ts @@ -5,6 +5,7 @@ import { JWSProcessor } from './jws/jws-processor.js' import { SHC } from './shc.js' import type { FHIRBundle, + Issuer, SHCConfig, SHCConfigParams, SHCJWT, @@ -79,9 +80,13 @@ export class SHCIssuer { * }); * ``` */ - async issue(fhirBundle: FHIRBundle, config: VerifiableCredentialParams = {}): Promise { + async issue( + fhirBundle: FHIRBundle, + config: VerifiableCredentialParams = {}, + issuerInfo: Issuer[] = [] + ): Promise { const jws = await this.createJWS(fhirBundle, config) - return new SHC(jws, fhirBundle) + return new SHC(jws, fhirBundle, issuerInfo) } /** diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 217356f..7d40735 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -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' /** @@ -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() @@ -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 diff --git a/src/shc/shc.ts b/src/shc/shc.ts index 72de478..e1fcfca 100644 --- a/src/shc/shc.ts +++ b/src/shc/shc.ts @@ -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. @@ -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[] = [] ) {} /** @@ -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. * diff --git a/src/shc/types.ts b/src/shc/types.ts index 8b634a7..f72d8e0 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -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. @@ -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 } /** @@ -288,3 +297,72 @@ export interface QRCodeConfigParams { * @category Configuration */ export type QRCodeConfig = Required + +/** + * 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[] + }[] +} diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts new file mode 100644 index 0000000..2e12f7b --- /dev/null +++ b/test/shc/directory.test.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Directory } from '../../src/shc/directory' +import type { DirectoryJSON } from '../../src/shc/types' + +describe('Directory', () => { + const ISS_URL = 'https://example.com/issuer' + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should create a directory from JSON', () => { + const directoryJson = { + directory: 'https://example.com/keystore/directory.json', + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + name: 'Example Issuer 1', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-1-simple', + }, + { + kty: 'EC', + kid: 'kid-2-simple', + }, + ], + crls: [ + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer2', + name: 'Example Issuer 2', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-A-simple', + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer3', + name: 'Example Issuer 3', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-C-simple', + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer4', + name: 'Example Issuer 4', + website: 'https://example.com/issuer4', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-D-simple', + }, + ], + crls: [ + { + kid: 'kid-D-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-2'], + }, + ], + }, + ], + } + const directory = Directory.fromJSON(directoryJson as DirectoryJSON) + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(4) + + const issuer1 = issuers[0]! + expect(issuer1.iss).toEqual('https://example.com/issuer') + expect(issuer1.keys).toHaveLength(2) + const crls1 = issuer1.crls! + expect(crls1).toHaveLength(1) + expect(crls1[0]!.kid).toEqual('kid-2-simple') + + const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')! + expect(issuer2).toBeDefined() + expect(issuer2.keys).toHaveLength(1) + + const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')! + expect(issuer3).toBeDefined() + expect(issuer3.keys).toHaveLength(1) + + const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')! + expect(issuer4).toBeDefined() + expect(issuer4.keys).toHaveLength(1) + const crls4 = issuer4.crls! + expect(crls4).toHaveLength(1) + }) + + it('should handle missing or invalid values when building directory using fromJSON', () => { + const directoryJson = { + directory: 'https://example.com/keystore/directory.json', + issuerInfo: [ + { + issuer: { + iss: 'https://missing.example/issuer', + name: 'Missing Issuer', + }, + // keys and crls intentionally missing + }, + { + issuer: { + iss: 'https://invalid.example/issuer', + name: 'Invalid Issuer', + }, + // keys and crls present but invalid types + keys: 'not-an-array' as any, + crls: null as any, + }, + { + issuer: { + // non-string iss should be coerced to '' + iss: 123 as any, + name: 'NonString Issuer', + }, + // keys and crls omitted + }, + ], + } + + const directory = Directory.fromJSON(directoryJson as DirectoryJSON) + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(3) + + const missing = issuers.find(i => i.iss === 'https://missing.example/issuer')! + expect(missing).toBeDefined() + expect(missing.keys).toEqual([]) + expect(missing.crls).toEqual([]) + + const invalid = issuers.find(i => i.iss === 'https://invalid.example/issuer')! + expect(invalid).toBeDefined() + expect(invalid.keys).toEqual([]) + expect(invalid.crls).toEqual([]) + + const nonstring = issuers.find(i => i.iss === '')! + expect(nonstring).toBeDefined() + expect(nonstring.keys).toEqual([]) + expect(nonstring.crls).toEqual([]) + }) + + it('should create a directory from a list of issuer urls and fetch jwks and crls', async () => { + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + keys: [ + { + kid: 'kid1', + kty: 'EC', + }, + { + kid: 'kid2', + kty: 'EC', + }, + ], + }), + }) + } + + if (url === `${ISS_URL}/.well-known/crl/kid2.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + kid: 'kid2', + method: 'rid', + ctr: 1, + rids: ['imrevoked'], + }), + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + const directory = await Directory.fromURLs([ISS_URL]) + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(1) + const issuer = issuers[0]! + expect(issuer.iss).toEqual(ISS_URL) + // Only one CRL should be collected (kid1 failed) + expect(issuer.crls).toHaveLength(1) + expect(issuer.crls![0]!.kid).toEqual('kid2') + // Both keys should be present + expect(issuer.keys).toHaveLength(2) + + expect(debugSpy).toHaveBeenCalledTimes(1) + expect(debugSpy).toHaveBeenCalledWith( + `Failed to fetch crl at ${ISS_URL}/.well-known/crl/kid1.json with status 404, skipping key.` + ) + + ;(globalThis as any).fetch = originalFetch + }) + + it('should create a directory from multiple issuer urls and fetch jwks and crls for each of them', async () => { + const ISS_URL2 = 'https://example.org/issuer2' + const ISS_URL3 = 'https://example.net/issuer3' + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + // issuer 1 jwks and crl + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + keys: [ + { kid: 'kid1', kty: 'EC' }, + { kid: 'kid2', kty: 'EC' }, + ], + }), + }) + } + if (url === `${ISS_URL}/.well-known/crl/kid2.json`) { + return Promise.resolve({ ok: true, json: async () => ({ kid: 'kid2' }) }) + } + + // issuer 2 jwks and crl + if (url === `${ISS_URL2}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ keys: [{ kid: 'kidA', kty: 'EC' }] }), + }) + } + if (url === `${ISS_URL2}/.well-known/crl/kidA.json`) { + return Promise.resolve({ ok: true, json: async () => ({ kid: 'kidA' }) }) + } + + // simulate jwks fetch failure for issuer3 + if (url === `${ISS_URL3}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: false, + status: 404, + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + const directory = await Directory.fromURLs([ISS_URL, ISS_URL2, ISS_URL3]) + const issuers = directory.getIssuerInfo() + // issuer3 jwks fetch will throw and be caught; only issuer1 and issuer2 should be present + expect(issuers).toHaveLength(2) + + const issuer1 = issuers.find(i => i.iss === ISS_URL)! + const issuer2 = issuers.find(i => i.iss === ISS_URL2)! + const issuer3 = issuers.find(i => i.iss === ISS_URL3) + + expect(issuer1).toBeDefined() + expect(issuer1.keys).toHaveLength(2) + expect(issuer1.crls).toHaveLength(1) + + expect(issuer2).toBeDefined() + expect(issuer2.keys).toHaveLength(1) + expect(issuer2.crls).toHaveLength(1) + + // issuer3 should not be present due to JWKS fetch failure + expect(issuer3).toBeUndefined() + + expect(debugSpy).toHaveBeenCalledTimes(2) + expect(debugSpy).toHaveBeenCalledWith( + `Failed to fetch crl at ${ISS_URL}/.well-known/crl/kid1.json with status 404, skipping key.` + ) + expect(debugSpy).toHaveBeenCalledWith( + `Failed to fetch jwks at ${ISS_URL3}/.well-known/jwks.json with status 404, skipping issuer.` + ) + + ;(globalThis as any).fetch = originalFetch + }) + + it('should handle jwks fetch failure gracefully and return empty directory', async () => { + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: false, + status: 404, + }) + } + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + const directory = await Directory.fromURLs([ISS_URL]) + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(0) + + expect(debugSpy).toHaveBeenCalledTimes(1) + expect(debugSpy).toHaveBeenCalledWith( + `Failed to fetch jwks at ${ISS_URL}/.well-known/jwks.json with status 404, skipping issuer.` + ) + + ;(globalThis as any).fetch = originalFetch + }) + + it('should log error when fetch throws and return empty directory', async () => { + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation(() => Promise.reject(new Error('fetch failed'))) + ;(globalThis as any).fetch = fetchMock + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const directory = await Directory.fromURLs([ISS_URL]) + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(0) + + expect(errorSpy).toHaveBeenCalledTimes(1) + + ;(globalThis as any).fetch = originalFetch + }) +}) diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index 25f05bc..6e0b896 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -1,5 +1,5 @@ // biome-ignore-all lint/suspicious/noExplicitAny: The test needs to use `any` to test error cases -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { BundleValidationError, type FHIRBundle, @@ -11,6 +11,7 @@ import { type SHCReaderConfigParams, SignatureVerificationError, } from '@/index' +import { Directory } from '@/shc/directory' import { createInvalidBundle, createValidFHIRBundle, @@ -57,6 +58,71 @@ describe('SHC', () => { expect(jws.split('.')).toHaveLength(3) }) + it('should bundle issuerInfo into SHC when reader created with a directory', async () => { + const { importPKCS8, importSPKI } = await import('jose') + + const privateKeyCrypto = await importPKCS8(testPrivateKeyPKCS8, 'ES256') + const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256') + + const configWithCryptoKeys: SHCConfig = { + issuer: 'https://example.com/issuer', + privateKey: privateKeyCrypto, + publicKey: publicKeyCrypto, + expirationTime: null, + enableQROptimization: false, + strictReferences: true, + } + const issuerWithCryptoKeys = new SHCIssuer(configWithCryptoKeys) + + const healthCard = await issuerWithCryptoKeys.issue(validBundle) + const jws = healthCard.asJWS() + + const ISS_URL = 'https://example.com/issuer' + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + keys: [ + { + kid: 'kid1', + kty: 'EC', + }, + ], + }), + }) + } + + if (url === `${ISS_URL}/.well-known/crl/kid1.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + kid: 'kid1', + method: 'rid', + ctr: 1, + rids: ['imrevoked'], + }), + }) + } + + return Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) + }) + ;(globalThis as any).fetch = fetchMock + const directory = await Directory.fromURLs([ISS_URL]) + ;(globalThis as any).fetch = originalFetch + + const readerWithDirectory = new SHCReader({ + publicKey: publicKeyCrypto, + enableQROptimization: false, + strictReferences: true, + issuerDirectory: directory, + }) + + const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) + expect(verifiedHealthCard.getIssuerInfo()).toEqual(directory.getIssuerInfo()) + }) + it('should issue SMART Health Card with CryptoKey objects', async () => { const { importPKCS8, importSPKI } = await import('jose')