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 backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "proxy-smart-backend",
"displayName": "Proxy Smart Backend",
"version": "0.3.15-beta.202608132031.ec7491a2e",
"version": "0.3.15-alpha.202608141759.af17725a9",
"type": "module",
"scripts": {
"test": "bun test --isolate",
Expand Down
121 changes: 121 additions & 0 deletions backend/src/lib/capability-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: Max Health Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial

/**
* Advertise THIS proxy's OAuth endpoints in the CapabilityStatement it serves.
*
* The upstream FHIR server does not know it is behind a SMART authorization layer: HAPI returns
* `rest[].security` empty, and /metadata was passed through with URL rewriting only. So a client
* doing the CapabilityStatement half of SMART discovery found no endpoints and gave up, even though
* `.well-known/smart-configuration` next door was complete.
*
* SMART App Launch defines both, and the extension is the one clients have used since v1 —
* @babelfhir-ts/smart-auth reads smart-configuration first and falls back here, and that fallback
* dead-ended against our own server. The values come from the same service that builds
* smart-configuration, so the two documents cannot disagree.
*/

const OAUTH_URIS = 'http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris'
const RESTFUL_SECURITY_SERVICE = 'http://terminology.hl7.org/CodeSystem/restful-security-service'

/** The endpoints worth advertising, in SMART's extension names. Absent ones are simply omitted. */
const ADVERTISED = [
['authorize', 'authorization_endpoint'],
['token', 'token_endpoint'],
['register', 'registration_endpoint'],
['introspect', 'introspection_endpoint'],
['revoke', 'revocation_endpoint'],
['manage', 'management_endpoint'],
] as const

/** Only the fields this module reads; the real document has many more. */
export interface SmartEndpoints {
authorization_endpoint?: string
token_endpoint?: string
registration_endpoint?: string
introspection_endpoint?: string
revocation_endpoint?: string
management_endpoint?: string
}

interface Extension {
url: string
valueUri?: string
extension?: Extension[]
}

interface RestEntry {
mode?: string
security?: {
service?: { coding?: { system?: string; code?: string; display?: string }[]; text?: string }[]
extension?: Extension[]
}
}

interface CapabilityStatement {
resourceType?: string
rest?: RestEntry[]
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

/**
* Add the `oauth-uris` extension and the SMART security service coding to every server-mode `rest`
* entry, and return the document as a string.
*
* NEVER THROWS AND NEVER REPLACES. A body that is not a CapabilityStatement, or a `rest` entry that
* already advertises `oauth-uris`, is returned untouched — a server that knows its own endpoints
* better than we do keeps them, and a malformed upstream response is still passed through rather
* than turned into an error the client cannot act on.
*/
export function withSmartSecurity(body: string, endpoints: SmartEndpoints): string {
if (!endpoints.authorization_endpoint || !endpoints.token_endpoint) return body

let parsed: unknown
try {
parsed = JSON.parse(body)
} catch {
return body
}
if (!isRecord(parsed)) return body

const statement: CapabilityStatement = parsed
if (statement.resourceType !== 'CapabilityStatement' || !Array.isArray(statement.rest)) return body

let changed = false
for (const entry of statement.rest) {
// `mode` is optional upstream; absent means the only rest entry, which is the server's.
if (entry.mode && entry.mode !== 'server') continue
if (annotate(entry, endpoints)) changed = true
}

return changed ? JSON.stringify(statement) : body
}

function annotate(entry: RestEntry, endpoints: SmartEndpoints): boolean {
const security = (entry.security ??= {})
const extensions = (security.extension ??= [])
if (extensions.some((extension) => extension.url === OAUTH_URIS)) return false

const uris: Extension[] = []
for (const [name, field] of ADVERTISED) {
const value = endpoints[field]
if (value) uris.push({ url: name, valueUri: value })
}
extensions.push({ url: OAUTH_URIS, extension: uris })

const services = (security.service ??= [])
const declared = services.some((service) =>
service.coding?.some((coding) => coding.code === 'SMART-on-FHIR'),
)
if (!declared) {
services.push({
coding: [{ system: RESTFUL_SECURITY_SERVICE, code: 'SMART-on-FHIR', display: 'SMART-on-FHIR' }],
text: 'OAuth2 using SMART-on-FHIR profile (see http://www.hl7.org/fhir/smart-app-launch)',
})
}

return true
}
72 changes: 58 additions & 14 deletions backend/src/routes/api/shl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,37 @@ function isJsonContentType(contentType: string | null): boolean {
return !!contentType && /json/i.test(contentType)
}

/**
* The smart-api-access document the recipient decrypts (SHL spec §3.2).
*
* Derived from the session on every manifest fetch rather than frozen at mint,
* because what it asserts — how long the token is good for, whether the share is
* complete — are properties of the share as it stands now. Freezing them meant a
* link kept telling recipients it carried the full record after the rule deciding
* that was corrected, since the claim sat inside ciphertext minted days earlier.
*/
export function buildSmartApiAccess(session: {
sessionToken: string
patientId: string
expiresAt: number
shareScope?: ShareScope
studyInstanceUID?: string
}): string {
return JSON.stringify({
access_token: session.sessionToken,
token_type: 'Bearer',
expires_in: Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000)),
scope: 'patient/*.read',
patient: session.patientId,
// aud points to our FHIR proxy — the viewer never talks to the real FHIR server.
aud: `${config.baseUrl}/api/shl/fhir`,
complete: isCompleteShare({
selectiveScope: session.shareScope,
studyInstanceUID: session.studyInstanceUID,
}),
})
}

/** Build the pure SelectiveScope from a session's persisted shareScope (all-empty when absent). */
function sessionSelectiveScope(session: { shareScope?: ShareScope }): SelectiveScope {
return {
Expand Down Expand Up @@ -438,19 +469,15 @@ export const shlRoutes = new Elysia({ prefix: '/shl', tags: ['shl'] })
? { excludedTypes, excludedIds, excludedObservationCategories }
: undefined

// Build the SMART API Access token response (per SHL spec)
// aud points to our FHIR proxy — the viewer never talks to the real FHIR server.
// `complete` is a non-standard hint for our own viewer: false when the patient
// de-selected records, so the recipient can be told the summary is partial
// (qualitative only — no counts leak). Only the key holder can read it (JWE).
const smartApiAccess = JSON.stringify({
access_token: sessionToken,
token_type: 'Bearer',
expires_in: ttlSeconds,
scope: 'patient/*.read',
patient: patientId,
aud: `${config.baseUrl}/api/shl/fhir`,
complete: isCompleteShare({ selectiveScope: shareScope, studyInstanceUID: body.studyInstanceUID }),
// The same document the manifest re-derives on every fetch — built once here
// so a freshly minted link and a later fetch cannot disagree. `complete` is a
// non-standard hint for our own viewer, readable only by the key holder (JWE).
const smartApiAccess = buildSmartApiAccess({
sessionToken,
patientId,
expiresAt,
shareScope,
studyInstanceUID: body.studyInstanceUID,
})

// Generate SHL using kill-the-clipboard
Expand Down Expand Up @@ -614,12 +641,29 @@ export const shlRoutes = new Elysia({ prefix: '/shl', tags: ['shl'] })
recipient: body.recipient,
})

// Re-derive the access document from the session so it describes the share as
// it is now. Falls back to the stored blob only if encryption fails, since a
// stale claim still beats handing the recipient an unopenable link.
let embedded = entry.jwe
try {
embedded = await encryptSHLFile({
content: buildSmartApiAccess(entry),
key: entry.shl.key,
contentType: SMART_API_ACCESS,
})
} catch (error) {
logger.auth.error('Could not rebuild the SHL access document — serving the one stored at mint', {
shlId: params.id,
error: error instanceof Error ? error.message : String(error),
})
}

// Return spec-compliant SHL manifest
// The JWE compact string goes directly in `embedded` (not wrapped in custom JSON)
return {
files: [{
contentType: SMART_API_ACCESS as string,
embedded: entry.jwe,
embedded,
}],
}
}, {
Expand Down
21 changes: 20 additions & 1 deletion backend/src/routes/fhir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { config } from '../config'
import { fhirServerStore, getServerByName, getServerInfoByName } from '../lib/fhir-server-store'
import { CommonErrorResponses, ErrorResponse, CacheRefreshResponse, SmartConfigurationResponse, FhirProxyResponse, type SmartConfigurationResponseType } from '../schemas'
import { smartConfigService } from '../lib/smart-config'
import { withSmartSecurity } from '../lib/capability-security'
import { logger } from '../lib/logger'
import { fetchWithMtls, getMtlsConfig } from './fhir-servers'
import { checkConsentWithIal, getConsentConfig } from '../lib/consent'
Expand Down Expand Up @@ -59,8 +60,11 @@ async function getCachedMetadata(
serverUrl,
`${config.baseUrl}/${config.name}/${serverName}/${fhirVersion}`,
)
// Upstream does not know it is behind a SMART layer, so it advertises no OAuth endpoints. We
// are the layer, so we add them from the same service that builds .well-known/smart-configuration.
const annotated = await annotateSecurity(replaced)
const contentType = resp.headers.get('content-type') || 'application/fhir+json'
const entry: MetadataCacheEntry = { body: replaced, contentType, status: resp.status, expiresAt: now + METADATA_TTL_MS }
const entry: MetadataCacheEntry = { body: annotated, contentType, status: resp.status, expiresAt: now + METADATA_TTL_MS }
// Only cache successes; let transient upstream errors retry on the next hit.
if (resp.status === 200) metadataCache.set(key, entry)
return entry
Expand All @@ -70,6 +74,21 @@ async function getCachedMetadata(
try { return await promise } finally { metadataInflight.delete(key) }
}

/**
* Add this proxy's OAuth endpoints to a CapabilityStatement. Discovery being unavailable must not
* make /metadata unavailable, so a failure here serves the upstream document unchanged.
*/
async function annotateSecurity(body: string): Promise<string> {
try {
return withSmartSecurity(body, await smartConfigService.getSmartConfiguration())
} catch (error) {
logger.fhir.warn('could not advertise SMART endpoints in the CapabilityStatement', {
error: error instanceof Error ? error.message : String(error),
})
return body
}
}

/** The path params this proxy is mounted on, plus what the handler uses. */
interface FhirProxyContext {
params: { server_name: string; fhir_version: string }
Expand Down
113 changes: 113 additions & 0 deletions backend/test/capability-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Max Health Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial

/**
* Advertising our OAuth endpoints in the CapabilityStatement we serve.
*
* THE FAILURE THIS FIXES. HAPI does not know it sits behind a SMART authorization layer, so it
* returns `rest[].security` empty and /metadata was passed through with URL rewriting only. A client
* doing the CapabilityStatement half of SMART discovery therefore found nothing and gave up, while
* .well-known/smart-configuration next door was complete. Our own provisioning job hit exactly that
* and a member's record went unwritten.
*
* What is pinned is mostly what must NOT happen: replacing endpoints a server already declares, and
* turning an unexpected upstream body into an error instead of passing it through.
*/
import { describe, it, expect } from 'bun:test'
import { withSmartSecurity } from '../src/lib/capability-security'

const OAUTH_URIS = 'http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris'

const ENDPOINTS = {
authorization_endpoint: 'https://api.example.com/auth/authorize',
token_endpoint: 'https://api.example.com/auth/token',
registration_endpoint: 'https://api.example.com/auth/register',
}

const capability = (rest: unknown[]) =>
JSON.stringify({ resourceType: 'CapabilityStatement', status: 'active', rest })

/** The oauth-uris sub-extensions of the first rest entry, as a plain name -> uri map. */
function advertised(body: string): Record<string, string> {
const parsed = JSON.parse(body)
const oauth = parsed.rest[0].security.extension.find((e: { url: string }) => e.url === OAUTH_URIS)
const map: Record<string, string> = {}
for (const entry of oauth?.extension ?? []) map[entry.url] = entry.valueUri
return map
}

describe('withSmartSecurity', () => {
it('advertises the endpoints a headless client needs', () => {
const result = withSmartSecurity(capability([{ mode: 'server' }]), ENDPOINTS)

expect(advertised(result)).toEqual({
authorize: ENDPOINTS.authorization_endpoint,
token: ENDPOINTS.token_endpoint,
register: ENDPOINTS.registration_endpoint,
})
})

it('declares SMART-on-FHIR as the security service', () => {
const result = withSmartSecurity(capability([{ mode: 'server' }]), ENDPOINTS)

const codes = JSON.parse(result).rest[0].security.service.flatMap(
(s: { coding?: { code?: string }[] }) => s.coding?.map((c) => c.code) ?? [],
)
expect(codes).toContain('SMART-on-FHIR')
})

it('annotates a rest entry with no mode, which is the only one', () => {
const result = withSmartSecurity(capability([{}]), ENDPOINTS)

expect(advertised(result).token).toBe(ENDPOINTS.token_endpoint)
})

it('leaves a server that already advertises its own endpoints alone', () => {
// It knows better than we do; overwriting would point clients at the wrong authorization server.
const existing = capability([
{
mode: 'server',
security: {
extension: [{ url: OAUTH_URIS, extension: [{ url: 'token', valueUri: 'https://theirs/token' }] }],
},
},
])

expect(withSmartSecurity(existing, ENDPOINTS)).toBe(existing)
})

it('omits endpoints that are not configured rather than emitting empty ones', () => {
const result = withSmartSecurity(capability([{ mode: 'server' }]), {
authorization_endpoint: ENDPOINTS.authorization_endpoint,
token_endpoint: ENDPOINTS.token_endpoint,
})

expect(Object.keys(advertised(result))).toEqual(['authorize', 'token'])
})

it('passes the body through when discovery gave us nothing to advertise', () => {
const body = capability([{ mode: 'server' }])

// /metadata staying up matters more than annotating it.
expect(withSmartSecurity(body, {})).toBe(body)
})

it('passes through anything that is not a CapabilityStatement', () => {
for (const body of ['not json at all', '{"resourceType":"OperationOutcome"}', '[]', 'null']) {
expect(withSmartSecurity(body, ENDPOINTS)).toBe(body)
}
})

it('keeps the rest of the document intact', () => {
const body = JSON.stringify({
resourceType: 'CapabilityStatement',
status: 'active',
fhirVersion: '4.0.1',
rest: [{ mode: 'server', resource: [{ type: 'Patient' }] }],
})

const parsed = JSON.parse(withSmartSecurity(body, ENDPOINTS))
expect(parsed.fhirVersion).toBe('4.0.1')
expect(parsed.rest[0].resource).toEqual([{ type: 'Patient' }])
})
})
Loading