Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9c9b19b
fix(shl): a study-scoped share is not a complete record
quotentiroler Aug 13, 2026
ec7491a
chore: sync package versions
quotentiroler Aug 13, 2026
4378121
πŸ”„ Update version to 0.3.15-alpha.202608132031.ec7491a2e (alpha) [skip…
github-actions[bot] Aug 13, 2026
5d54a23
Merge pull request #1021 from Max-Health-Inc/develop
proxy-smart-releaser[bot] Aug 13, 2026
1119178
chore: absorb main branch (resolve version conflicts) [proxy-smart-re…
proxy-smart-releaser[bot] Aug 13, 2026
212f857
πŸ”„ Update version to 0.3.15-beta.202608132031.ec7491a2e (beta) [skip ci]
github-actions[bot] Aug 13, 2026
3ea80e8
fix(smart): headless clients could not discover this server at all
quotentiroler Aug 14, 2026
af17725
fix(shl): derive the access document per manifest fetch, not once at …
quotentiroler Aug 14, 2026
c4b74fd
πŸ”„ Update version to 0.3.15-alpha.202608141759.af17725a9 (alpha) [skip…
github-actions[bot] Aug 14, 2026
3bd0951
Merge pull request #1023 from Max-Health-Inc/develop
proxy-smart-releaser[bot] Aug 14, 2026
bd3ddf5
πŸ”„ Update version to 0.3.15-beta.202608141759.af17725a9 (beta) [skip ci]
github-actions[bot] Aug 14, 2026
061617f
fix(keycloak): stop reconciling a JWKS URL Keycloak cannot reach
quotentiroler Aug 14, 2026
36af3ff
chore: sync package versions
quotentiroler Aug 14, 2026
9961109
πŸ”„ Update version to 0.3.15-alpha.202608142040.36af3ff76 (alpha) [skip…
github-actions[bot] Aug 14, 2026
dd5b6e1
Merge pull request #1024 from Max-Health-Inc/develop
proxy-smart-releaser[bot] Aug 14, 2026
e4544b5
πŸ”„ Update version to 0.3.15-beta.202608142040.36af3ff76 (beta) [skip ci]
github-actions[bot] Aug 14, 2026
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.14-RELEASE.202608131946.961ab42c4",
"version": "0.3.15-beta.202608142040.36af3ff76",
"type": "module",
"scripts": {
"test": "bun test --isolate",
Expand Down
9 changes: 9 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ try {
export const config = {
baseUrl: process.env.BASE_URL || 'http://localhost:8445',
port: process.env.PORT || 8445,

/**
* Where KEYCLOAK fetches this backend's JWKS to verify proxy-signed assertions.
*
* Set it whenever Keycloak cannot reach us at the docker-compose service name `backend` β€” on ECS
* there is no such host, and the derived URL silently breaks every private_key_jwt client with
* `invalid_client`. The public base URL works there, since Keycloak has egress to the load balancer.
*/
proxySigningJwksUrl: process.env.PROXY_SIGNING_JWKS_URL || null,

// Application name and version from package.json
name: packageJson.name,
Expand Down
26 changes: 18 additions & 8 deletions backend/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ensureAdminUiDeviceGrant,
} from './lib/kc-system-provisioning'
import KcAdminClient from '@keycloak/keycloak-admin-client'
import { proxySigningJwksUrl, isReachableFromKeycloak } from '@/lib/proxy-signing-url'

// Global state to track Keycloak connectivity
let keycloakAccessible = false
Expand Down Expand Up @@ -699,15 +700,24 @@ async function ensureProxySigningIdp(): Promise<void> {
const token = await admin.getAccessToken()
const idpUrl = `${config.keycloak.baseUrl}/admin/realms/${config.keycloak.realm}/identity-provider/instances/${IDP_ALIAS}`

// Compute the internal JWKS URL (how KC reaches the backend within Docker)
// If KEYCLOAK_BASE_URL has an internal hostname (e.g., http://keycloak:8080/auth),
// we know we're in Docker and the backend is at http://backend:PORT.
// Otherwise (localhost), the backend is also on localhost.
const kcHost = new URL(config.keycloak.baseUrl!).hostname
const internalBackendHost = (kcHost !== 'localhost' && kcHost !== '127.0.0.1')
? 'backend'
: 'localhost'
const jwksUrl = `http://${internalBackendHost}:${config.port}/.well-known/jwks.json`
const jwksUrl = proxySigningJwksUrl(kcHost, config.proxySigningJwksUrl, config.port)

/*
* Refuse to write a URL Keycloak cannot resolve. `backend` is a docker-compose service name, and
* on ECS (or any host-per-service deployment) there is nothing behind it β€” Keycloak then cannot
* fetch our JWKS, cannot verify a proxy-signed assertion, and EVERY private_key_jwt client fails
* with `invalid_client`. That was production for months. Leaving a correct config in place beats
* replacing it with a broken one, so this reconciles nothing and says why.
*/
if (!isReachableFromKeycloak(jwksUrl, kcHost)) {
logger.keycloak.warn(
'Refusing to reconcile proxy-smart-signing: the derived JWKS URL is unreachable from Keycloak. ' +
'Set PROXY_SIGNING_JWKS_URL to a URL Keycloak can fetch (the public base URL works when it has egress).',
{ jwksUrl, keycloakHost: kcHost },
)
return
}

// Check if the IdP already exists
const getRes = await fetch(idpUrl, {
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
}
50 changes: 50 additions & 0 deletions backend/src/lib/proxy-signing-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: Max Health Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial

/**
* Where Keycloak fetches this backend's JWKS to verify proxy-signed assertions.
*
* Keycloak reaches it through the `proxy-smart-signing` IdP, and the URL was derived as
* `http://backend:<port>` whenever Keycloak's host was not loopback β€” a docker-compose service name.
* On ECS nothing answers to `backend`, so Keycloak could verify nothing and every private_key_jwt
* client failed with `invalid_client`.
*
* Its own module so the rules are testable without importing `init`, whose import starts a server.
*/

/** Hosts that only ever mean "this container", and so can never be another service's address. */
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);

/**
* The JWKS URL to advertise, given where Keycloak lives.
*
* `configured` wins, because only the deployment knows how its network is wired. Without it the
* docker-compose assumption is kept β€” a non-loopback Keycloak means a compose network where this
* backend answers to `backend` β€” which is what beta runs on and must keep working.
*/
export function proxySigningJwksUrl(
keycloakHost: string,
configured: string | null,
port: number | string,
): string {
if (configured) return configured;
const host = LOOPBACK.has(keycloakHost) ? 'localhost' : 'backend';
return `http://${host}:${port}/.well-known/jwks.json`;
}

/**
* Whether Keycloak could plausibly fetch this URL.
*
* The one case worth refusing is a loopback JWKS URL while Keycloak lives somewhere else: that is
* always wrong and always silent, and it is what production held. Anything else is the deployment's
* business β€” a compose service name is unresolvable from here and perfectly resolvable from there.
*/
export function isReachableFromKeycloak(jwksUrl: string, keycloakHost: string): boolean {
let host: string;
try {
host = new URL(jwksUrl).hostname;
} catch {
return false;
}
return !LOOPBACK.has(host) || LOOPBACK.has(keycloakHost);
}
18 changes: 18 additions & 0 deletions backend/src/lib/shl-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,24 @@ interface FhirBundleLike {
[key: string]: unknown
}

/**
* True only when NOTHING narrows the share.
*
* The recipient is told "complete summary β€” the patient shared their full health
* record" on the strength of this, so every dimension that narrows a share has to
* be counted here. It lives beside the scope rules rather than inline at the mint
* site because that is how it drifted: it knew about selective de-selection and
* not about study scoping, so a single-study link claimed to carry everything
* while the proxy answered almost every query with 403 β€” which the viewer drew as
* "no allergies, no medications, no conditions".
*/
export function isCompleteShare(narrowing: {
selectiveScope?: unknown
studyInstanceUID?: string
}): boolean {
return !narrowing.selectiveScope && !narrowing.studyInstanceUID
}

/** True when the scope actually narrows anything (else all helpers are no-ops). */
export function isSelectiveScopeActive(scope: SelectiveScope): boolean {
return (
Expand Down
73 changes: 59 additions & 14 deletions backend/src/routes/api/shl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { shlSessionStore, type ShareScope, type ShlSession } from '@/lib/shl-ses
import {
isDicomPathAllowed,
scopeFhirRequest,
isCompleteShare,
isSelectiveScopeActive,
preScreenSelectiveRequest,
applySelectiveFilter,
Expand Down Expand Up @@ -91,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 @@ -437,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: !shareScope,
// 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 @@ -613,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
Loading