SMART App Launch 2.2.0 Checklist
Implementation status of the HL7 SMART App Launch requirements in Proxy Smart, checked against the code and against the Inferno runs described in Compliance Reports. A box is ticked only where the behaviour exists in code and, where Inferno covers it, passes there. Paths are relative to the repository root.
The proxy sits in front of Keycloak and one or more FHIR servers. Keycloak authenticates users and issues tokens; the proxy owns the SMART surface: discovery, the authorize and token endpoints, launch context, introspection, and scope enforcement on FHIR requests.
Conformance evidence
Inferno smart_app_launch_test_kit v1.0.0, suite smart_stu2_2, on 2026-10-13:
| Stage | Target | Result |
|---|---|---|
dev | Local CI stack | 95 passed, 0 failed, 7 omitted of 102 |
beta | Deployed VPS | 102 passed, 0 failed, 0 omitted of 102 |
The seven tests omitted on dev are the TLS checks, which do not apply to the plain-HTTP CI stack. Deployed stages run them, and beta passes all seven: it accepts TLS 1.2 and refuses 1.0 and 1.1. The four groups run are Standalone Launch, Token Introspection, Backend Services and EHR Launch. Production runs a read-only consent-enforcement probe rather than the suite; see testing/README.md.
Discovery
.well-known/smart-configuration is served per FHIR server at /{name}/{server}/{version}/.well-known/smart-configuration, built by backend/src/lib/smart-config.ts.
- [x]
authorization_endpoint,token_endpoint,introspection_endpoint,registration_endpoint, all absolute proxy URLs (SMART 2.1 absolute-URL rule) - [x]
capabilities,scopes_supported(both overridable withSMART_CAPABILITIES/SMART_SCOPES_SUPPORTED) - [x]
code_challenge_methods_supportedwithS256and neverplain, in every discovery document - [x]
grant_types_supported,response_types_supported,token_endpoint_auth_methods_supportedandtoken_endpoint_auth_signing_alg_values_supportedlist only what works through the proxy: its own supported list (PROXY_OAUTH_SUPPORTinbackend/src/lib/oidc-discovery.ts) intersected with Keycloak's. That isauthorization_code,refresh_token,client_credentials, token exchange anddevice_code; response typecode;client_secret_basic,client_secret_post,private_key_jwtandnone; andRS,PSandES256/384/512, the algorithms the proxy verifies client assertions with. Keycloak'simplicit,password, CIBA, UMA, hybrid response types,client_secret_jwt,tls_client_auth,HS*andEdDSAare left out./.well-known/openid-configurationand/.well-known/oauth-authorization-serveradvertise the same lists, and the OIDC document filters the introspection and revocation auth methods likewise - [x]
scopes_supportednames only scopes that exist as Keycloak client scopes, without Keycloak's plumbing (web-origins,roles,acr,microprofile-jwt,basic,service_account,resource-indicators). The advertised SMART scopes, the v2 wildcards such aspatient/*.rsincluded, are created in Keycloak at startup (backend/src/lib/smart-scope-provisioning.ts), so the spec's example requests do not fail withinvalid_scope - [x]
issuer(Keycloak's realm issuer) only whensso-openid-connectis advertised, as the spec requires;jwks_uriis Keycloak's key set. See Issuer identifiers below - [x] An unknown FHIR server or a FHIR version the proxy does not serve answers
404with a JSON error, and an unreachable Keycloak answers503with{"error":"temporarily_unavailable"}; every answer isapplication/json - [x]
end_session_endpoint(proxy/auth/logout) when Keycloak offers one - [x]
user_access_brand_bundleanduser_access_brand_identifier(SMART 2.2, RECOMMENDED) - [x]
revocation_endpoint, the proxy's/auth/revoke, also in the OAuth and OIDC discovery documents and the CapabilityStatementoauth-urisextension - [x]
management_endpoint: the consent app when one is configured (CONSENT_APP_URLor the consentappUrlsetting), otherwise Keycloak's account console applications page, where the user sees and revokes the apps they authorized - [x]
associated_endpoints(SMART 2.1, experimental): the proxy's App State endpoint, withcapabilities: ["smart-app-state"], emitted only whilesmart-app-stateis among the capabilities
Issuer identifiers
Two documents name an issuer, and they name different ones on purpose:
- The RFC 9207
isson every authorization response, and theissuerof the proxy's RFC 8414 metadata (/.well-known/oauth-authorization-server,/.well-known/openid-configuration), is the proxy origin (BASE_URL). MCP clients discover the proxy as their authorization server and compareissagainst that metadata. - The
issuerin.well-known/smart-configurationis Keycloak's realm issuer, because it is what theid_tokencarries iniss, and a SMART app validates theid_tokenagainst it andjwks_uri.
Unifying them would need the proxy to re-sign Keycloak's id_token under its own issuer and keys. It does not, so neither value changes.
Capabilities advertised by default
- [x]
launch-ehr,launch-standalone - [x]
authorize-post:POST /auth/authorizetakes the same parameters as a form body and runs the same handler asGET - [x]
client-public,client-confidential-symmetric,client-confidential-asymmetric - [x]
sso-openid-connect - [x]
context-ehr-patient,context-ehr-encounter,context-standalone-patient,context-standalone-encounter - [x]
context-banner,context-style:need_patient_bannerandsmart_style_urlare returned when the EHR launch supplies them - [x]
permission-offline,permission-online,permission-patient,permission-user,permission-v1,permission-v2 - [x]
smart-app-state(SMART 2.1, experimental), see App State below
Authorization
EHR launch
- [x]
POST /auth/launchmints a signed launch code for an authenticated caller, carrying patient, encounter, fhirUser, intent,fhirContext,need_patient_bannerandsmart_style_url(backend/src/routes/auth/oauth/launch.ts) - [x] Every code is bound to a client:
POST /auth/launchrequiresclientId, a Task launch takes the client of the matched app, and/auth/authorizerefuses a code bound to no client or to another one (packages/auth/src/authorize-interceptor.ts) - [x] A code is single use within its TTL: its
jtiis spent by the first authorization request that passes validation, in the store that holds client-assertionjtis, so it is shared across nodes through PostgreSQL (backend/src/lib/cache/jti-replay-cache.ts) - [x]
audequals the launch'siss: a code minted with aniss(optional onPOST /auth/launch, always set by a Task launch) carries that FHIR base, and an authorization request whoseaudnames another one getsinvalid_target - [x] A patient and an encounter in the same launch context are checked when the code is bound to a FHIR server: the Encounter is read through the proxy with the caller's token and must have that patient as
subject(backend/src/lib/launch-encounter.ts). A code minted withoutissis not checked, since the proxy cannot tell which server the EHR means - [x]
fhirContextinput follows the SMART rules or is refused with400: every item hasreference,canonicaloridentifier;roleis never empty and is an absolute URI unless it islaunch; Patient and Encounter appear only with a role other thanlaunch(packages/auth/src/fhir-context.ts) - [x] The context reaches the token response
- [x] An invalid, expired or spent
launchvalue, or one minted for another client, is refused:error=invalid_requestwith anerror_description,stateand RFC 9207isson the client's registeredredirect_uri, or a400when thatredirect_uriis not registered for the client
The EHR builds the app's launch URL with iss and launch; the proxy does not.
Standalone launch
- [x] Patient picker for
launch/patient(packages/patient-picker, routes inbackend/src/routes/auth/oauth/context-selection.ts) - [x] A
patient/resource scope triggers the same handling aslaunch/patient. SMART lets the EHR either refuse such a request or infer the scope; the proxy infers it (hasPatientCompartmentScopein@proxy-smart/auth) - [x] Encounter picker for
launch/encounter(packages/auth/src/encounter-selection.ts, routesGET /auth/encounter-searchandPOST /auth/encounter-select). Once the patient is settled, by the patient picker or from a PatientfhirUser, the picker offers that patient's encounters fromEncounter?patient=<id>&_sort=-date, at most 50, and the chosen id reaches the token response asencounter. Only an encounter the search returned is accepted. A patient with no encounters, or a search that failed, may proceed without one - [x]
launch/encounteralone also starts patient selection, since the encounter has to belong to a patient - [x] The patient submission is refused unless the callback gate cleared the session for the picker, so a session key cannot name an arbitrary patient
- [x] A patient is established whenever the request needs one (
launch/patientor apatient/scope), also for an EHR launch whose code names no patient: a practitioner gets the patient picker, including a Person resolved to or chosen as a Practitioner. Anyone else, a Person whose links name no usable identity, andprompt=none(which cannot show a picker) are refused (packages/auth/src/launch-gate.ts) - [x] A launch whose identity is only resolved at the token endpoint (a Person read that failed, or a user the callback could not identify) and that still has no patient there gets
invalid_grantinstead of a patient-level token without a patient (backend/src/routes/auth/oauth/token.ts). Keycloak has already issued its tokens at that point; they are discarded unseen
The encounter step is skipped under prompt=none, and when the patient is only resolved at the token endpoint (a Person fhirUser with no identity chosen at the callback). The token then carries no encounter, which the spec allows, since providing requested context is a SHOULD for the EHR.
Authorization request
- [x]
response_type=code,client_id,redirect_uri,scope,state - [x]
aud(or RFC 8707resource) is required when the request carries a SMART launch or resource scope, and must be the base of a configured FHIR server ({BASE_URL}/{name}/{server}/{version}, a trailing slash aside), the MCP endpoint, an enabled per-server MCP endpoint or an allowed external audience. A missingaudgetsinvalid_requestand an unknown oneinvalid_target(RFC 8707), both decided after theredirect_uriis known to be registered - [x] PKCE: every authorization request needs a
code_challengewithcode_challenge_method=S256. A missing challenge,plain, a missing method (which RFC 7636 reads asplain) or a challenge that cannot be a SHA-256 digest getsinvalid_request. Keycloak verifies thecode_verifierat the token endpoint for every code issued with a challenge, whatever the client's own PKCE setting (packages/auth/src/pkce.ts) - [x] Keycloak agrees: every client created through the admin API or dynamic registration that runs the authorization code flow gets
pkce.code.challenge.method=S256unless the admin API is toldrequirePkce: false, an update can set or clear it, and every seeded public client inkeycloak/realm-export.jsonrequires it - [x] Refusals by the authorization server go back to the client as RFC 6749 §4.1.2.1 redirects with
error,error_description,stateand RFC 9207isswhenever theredirect_uriis registered: bad PKCE, a missing or unknownaud, a bad launch code, the picker refusals (access_denied, witherror_uriset toPATIENT_ONBOARDING_URLfor an account linked to no record),interaction_requiredunderprompt=none, and an identity or encounter the session never offered. The launch session ends with the refusal. An HTML page or a JSON error remains only where no registered redirect is known: a missing or expired session, or an unregisteredredirect_uri - [x] Redirect URIs match exactly, or by a single trailing Keycloak
*wildcard. A wildcard match keeps the registration's origin after URL parsing, carries no userinfo and stays under the registered path once dot segments are resolved; a port wildcard (http://localhost:*) is honoured only for loopback hosts (packages/auth/src/redirect-uri.ts) - [x] Dynamic registration accepts only fixed, fully specified
redirect_uris: no*, no fragment, andhttpsunless the host is loopback (localhost,127.0.0.1,[::1]) - [ ] Fixed, fully specified redirect URIs only: dynamic registration refuses wildcards, but the seeded realm clients (
keycloak/realm-export.json) register origin and dev-port wildcards, which the proxy still honours so local and beta stacks keep working - [x] RFC 9207
isson the callback: the proxy origin, see Issuer identifiers
Token endpoint
- [x] Authorization code, refresh token, client credentials and RFC 8693 token exchange, forwarded to Keycloak (
backend/src/routes/auth/oauth/token.ts) - [x] Confidential symmetric clients authenticate with
client_secret_basicorclient_secret_post; the HTTP Basic header is passed to Keycloak unchanged - [x]
private_key_jwtassertions are verified by the proxy (iss = sub,aud= the proxy token endpoint,expat most five minutes out,jtinot reused, signature against the client's registered JWKS) and re-signed for Keycloak (backend/src/routes/auth/backend-services.ts). Ajtiis spent only once its signature verifies - [x] For a code the proxy intercepted, the app's own
redirect_uriis compared by the proxy, since Keycloak only saw the proxy callback: a differentredirect_uri, none, or anotherclient_idis refused withinvalid_grantbefore Keycloak is asked. A token request naming the proxy callback itself asredirect_uriis refused the same way (verifyCodeRedemptioninpackages/auth/src/token-enricher.ts) - [x] Launch context added to the response:
patient,encounter,fhirContext,need_patient_banner,smart_style_url,intent,tenant - [x] The launch context survives a refresh: it is stored by the refresh token's
jtitoo, and arefresh_tokengrant Keycloak accepted returns it again (scope-gated against the refreshed scope) and binds it to the new access and refresh tokens, so introspection and patient confinement keep working (backend/src/routes/auth/oauth/token-launch-context.ts) - [x]
fhirUsermade absolute against the FHIR server the authorization'saudnamed, and returned only whenopenidandfhirUserwere both granted. A Person reference resolves to its Patient link for a patient-facing client and to its Practitioner link otherwise - [x]
scopeis the scope Keycloak granted; no claim rewrites it - [x]
authorization_details(RFC 9396) listing the FHIR servers the token is good for - [x]
Cache-Control: no-storeandPragma: no-cache - [x] Errors are RFC 6749 §5.2 bodies with
no-storeon/auth/token,/auth/introspect,/auth/revoke,/auth/registerand/auth/device: a request that fails schema validation or cannot be parsed answers400 invalid_request(invalid_client_metadataat/auth/register), an unexpected failure500 server_error(backend/src/lib/oauth-errors.ts) - [x]
jkuin client assertions: when present it must equal the client's registered JWKS URL, elseinvalid_client; thejkuURL itself is never fetched - [x] Key selection follows the asymmetric profile's step 3: the header must carry a
kid, and exactly one key may match it with akty(andcrv) that fits the algorithm, auseofsigand no conflictingalg. No match, or more than one, isinvalid_client; there is no fallback to the first key (backend/src/lib/client-jwks.ts) - [x] Assertions are accepted only for
RS384andES384by default, independent of the header;CLIENT_ASSERTION_ALGORITHMSwidens the list, andtoken_endpoint_auth_signing_alg_values_supportedadvertises the same list
Access FHIR API
- [x] The resource server validates the token (signature, issuer, expiry, revocation) and that its scopes cover the request (see Scopes)
- [x] "The resource server also validates that the
audparameter associated with the authorization matches the resource server's own FHIR endpoint": a FHIR request's token is bound to the server it reaches when itsaudholds that server's FHIR base (route base, RFC 8707 resource URL or upstream base, exact match) or the resource client whoseresource_urlis that base, narrowed to it. A token for another FHIR server, or for another client of the realm, is refused with401 invalid_tokenunderFHIR_AUDIENCE_ENFORCEMENT=enforceand logged with the reason underaudit-only(backend/src/lib/fhir-audience-binding.ts) - [ ] Audience binding enforced by default: the code default is
audit-only(the beta deployment and the compliance CI runenforce, and Inferno passes there), because deployed tokens issued before the resource indicator was forwarded, and tokens of clients that bypass the proxy's authorize endpoint, carryaud: account. Only one FHIR resource client (fhir-resource-server) exists, holding one server's base, so on a deployment with several FHIR servers the tokens for the others cannot be narrowed to their base yet - [x] A SMART app's server restriction (
serverAccessTypeselected-serversoruser-person-servers,allowedServerIds) holds on every FHIR request its tokens make, batch and transaction entries and MCP tool calls included;403 server_not_permittedotherwise,503when Keycloak cannot be asked (backend/src/lib/client-server-access.ts)
Scopes
- [x] SMART v2 resource scopes
patient/,user/,system/withcruds(backend/src/lib/smart-access-control.ts, parsed inbackend/src/lib/smart-scope-grants.ts) - [x] SMART v1
read,writeand*, accepted at enforcement asrs,cudandcruds - [x] "Valid suffixes are a subset of the in-order string
.cruds": an out-of-order or repeated suffix (.sr,.dus,.rr) grants nothing, and the admin API refuses to create such a scope (SMART_SCOPE_PATTERN,isSMARTScope) - [x] Interactions mapped to permissions as the spec's table does: read, vread and instance history are
r; GET search,POST _search, type-level history and system-level history ares; POST isc; PUT and PATCH areu; DELETE and$eraseared - [x] Enforcement defaults to
enforce;audit-onlylogs and allows - [x] A search or history across all resource types at the server base (
[base]?_type=,POST [base]/_search,[base]/_history) needs an unconstrained wildcard search scope such assystem/*.rs, and an authenticated caller. A paging link (?_getpages=) needs a valid token only - [x]
_includeand_revinclude(with:iterate) need an unconstrainedrorsscope for every type they return, in the URL or the form body of aPOST _search. An include that does not name the type it returns (Observation:subject,*) is refused unless the token holds an unconstrained wildcard read scope (backend/src/lib/smart-include-access.ts) - [x] A compartment search such as
Patient/123/Observationis judged by the type it returns, so it needs anObservationscope - [x] Batch Bundles: each entry runs through the same per-request pipeline as a single request (operation policy, consent, tenant isolation, scopes, patient compartment, capability normalisation) and the answer is a
batch-response. A refused entry carries an OperationOutcome and does not fail the others (backend/src/lib/fhir-bundle-access.ts,backend/src/lib/fhir-request-pipeline.ts) - [x] Transaction Bundles: every entry gets the same decisions. One refusal refuses the transaction with an OperationOutcome naming the entry; an admitted transaction is forwarded upstream unchanged. A search the compartment, tenant or scope constraint would have to narrow is admitted only when the narrowing is already in its query
- [x] Bundle bodies are validated: a JSON Bundle of type
batchortransaction, at mostFHIR_BUNDLE_MAX_ENTRIESentries (default 100), each withrequest.methodand arequest.urlrelative to, or absolute against, the proxy base - [x] v2 scopes with search-parameter constraints (
patient/Observation.rs?category=...|laboratory,backend/src/lib/smart-scope-constraints.ts). A search gets the constraint added (several constrained scopes on one parameter become a FHIR OR), a read by id is checked upstream with_idplus the constraint, a create or update is checked against the body, and an update or delete against the stored resource. An unconstrained scope for the same type grants full access. Patch, conditional writes, history, operations,_includeand_revincludeare refused under a constrained scope, as is any constraint the proxy or the upstream server cannot evaluate - [ ] Constraints on writes are evaluated locally only for common token parameters (
category,code,status,clinical-status,verification-status,intent,type,class,identifier,gender,priority,criticality); anything else refuses the write - [ ] Issuing constrained scopes end to end: the admin scope tooling lists and creates them as SMART scopes, but no Inferno run or live Keycloak test yet shows a client scope named with a
?suffix reaching a token - [x]
launch,launch/patient,launch/encounter,openid,fhirUser,profile,offline_access,online_access - [x]
online_accessgets Keycloak's session-bound refresh token, which stops working once the user's SSO session ends (30 minutes idle, 10 hours at most in the shipped realm).offline_accessgets an offline token, which outlives the session and needs theoffline_accessrealm role. A refresh token is also issued when neither scope is requested, which SMART allows
Patient compartment
A grant through a patient/ scope can be narrowed to the token's patient, controlled by ROLE_BASED_FILTERING_MODE:
enforcenarrows searches to the patient (_id=<id>for Patient searches), refuses cross-patient reads, and refuses a patient-scoped token that carries no patientaudit-onlyevaluates the same decisions, upstream ownership checks included, logs whatenforcewould change and changes nothing, for apatient/grant and a user who is a patient alike. This is the code default; the beta deployment and the compliance CI runenforcedisabledturns the filter off
Each deployment declares its mode in its environment rather than inheriting the code default.
- [x] Reads cover every type in the FHIR R4 Patient CompartmentDefinition (
backend/src/lib/patient-compartment/definition.ts), each narrowed by its own search parameter (patient, orsubject,member,actorwhere R4 has nopatient), includingAppointment,Composition,List,Provenance,Claim,ExplanationOfBenefit,EpisodeOfCare,RelatedPerson,AdverseEventandDeviceUseStatement. The configurablePATIENT_SCOPED_RESOURCESlist is gone - [x] Server-base searches and history (
[base]?_type=,[base]/_history,POST [base]/_search, base operations) and type-level history and operations on compartment types (Observation/_history,Patient/$everything) are refused, since no search parameter can confine them; an instance operation checks ownership first, whatever its method - [x] Searches sent as
POST [type]/_searchare narrowed likeGETsearches, through the URL - [x]
Patient.link: "the server documentation SHALL describe its authorization behavior". Linked and merged Patient records are not part of the compartment; the grant reaches the one patient its context names, by id (docs/fhir-proxy.md, Linked Patients) - [x] Writes stay in the patient's compartment, per the FHIR R4 Patient CompartmentDefinition (
backend/src/lib/patient-compartment/): a create's body, an update's stored resource and new body, a patch's stored resource and a delete's stored resource must reference the in-context patient and no other patient. A patch may not touchidor a compartment element. Types outside the compartment, unresolvable references, non-JSON bodies, conditional and cascading writes are refused. The same rule confines a user who is a patient, write entries in batch and transaction Bundles, and the per-server MCP tools
fhirContext and fhirUser
- [x]
fhirContextwithreferenceentries (SMART 2.1) - [x]
fhirContextwithcanonicalandidentifierentries, plustypeandrole(SMART 2.2) - [x] The SHALL rules on
fhirContextitems are enforced where launch context enters the proxy (see EHR launch) - [x]
PractitionerRoleasfhirUser(SMART 2.1), kept for clinician-facing clients - [x] A
fhirUseris read as one of the types SMART allows:Patient,Practitioner,PractitionerRole,RelatedPerson,Person(backend/src/lib/patient-context.ts) - [x] Introspection returns the absolute
fhirUserof the token response, not the raw claim - [ ] The
fhirUserclaim inside theid_tokenis Keycloak's raw user attribute and may be relative; only the token response and introspection values are made absolute. The proxy cannot re-sign Keycloak'sid_token, and the attribute names no FHIR server, so an absolute value is only well defined for a single-server deployment
Token introspection
POST /auth/introspect (backend/src/routes/auth/oauth/introspection.ts) proxies Keycloak's RFC 7662 endpoint.
- [x] The caller authenticates as a resource server, with HTTP Basic,
client_secretin the body, or aprivate_key_jwtassertion that the proxy verifies and re-signs as at the token endpoint. A request without credentials gets401 invalid_client - [x] Keycloak decides
activefor that caller, so a token is active only for a resource server in its audience - [x] A SMART app or backend service may instead send its bearer token, as the spec requires of an access-controlled endpoint. The token must validate and carry the introspection scope (
token-introspection, set withSMART_INTROSPECTION_SCOPE, created as a Keycloak client scope at startup and assigned to the clients allowed to introspect). Such a caller may introspect any client's token, so the proxy asks Keycloak as its own introspection client, which is exempt from Keycloak's audience check. A token that does not validate gets401 invalid_token, one without the scope403 insufficient_scope - [x] The response repeats the token response's launch context:
patient,encounter,fhirUser,fhirContext,intent,need_patient_banner,smart_style_url,tenant, and theactchain of an exchanged token. It is stored whenever any of them was in the token response, sointentortenantalone come back too - [x]
scopeis the scope of the token response - [x] That context is shared through PostgreSQL when
DATABASE_URL(or thePG*parts) is set, so any node answers for a token another node issued (backend/src/lib/token-context-store.ts). Without a database it is held per process, and a multi-node deployment needs sticky routing
Token revocation
POST /auth/revoke (backend/src/routes/auth/oauth/revocation.ts) proxies Keycloak's RFC 7009 endpoint.
- [x] A confidential client authenticates with HTTP Basic (passed through unchanged),
client_secret, or aprivate_key_jwtassertion that the proxy verifies and re-signs; a public client sends onlyclient_id. A request naming no client gets401 invalid_client - [x] An unknown, invalid or expired token answers
200(RFC 7009 §2.2); a token issued to another client is refused by Keycloak - [x] A revoked access token's launch context is dropped, and the proxy's local token validation refuses its
jtiuntil it would have expired. A revoked refresh token's context goes too, so no later refresh can carry it - [x] The revoked-
jtilist reaches every node through PostgreSQL when a database is configured, within five seconds; without one it is per-process, and other nodes accept a revoked access token until it expires
Backend Services
- [x]
client_credentialswithprivate_key_jwtsigned RS384 or ES384, as the registered key allows, validated as described under Token endpoint - [x] Client keys from a registered JWKS URL or inline JWKS. A JWKS URL is fetched with an SSRF guard,
Accept: application/json, a timeout (JWKS_FETCH_TIMEOUT_MS) and no redirect following, since a redirect target would skip the guard - [x] A fetched JWKS is cached no longer than its
Cache-Controlallows:max-ageors-maxage, nothing forno-store,no-cacheormax-age=0,JWKS_CACHE_TTL_MSwhen the header is absent, capped atJWKS_CACHE_MAX_TTL_MS - [x] An unknown
kidreloads the client's keys once before failing, so a rotated key works at once; the reload is limited to one per client perJWKS_REFETCH_MIN_INTERVAL_MS - [x] Access tokens for clients with a service account live 300 seconds by default (
BACKEND_SERVICE_TOKEN_LIFESPAN), set as the client'saccess.token.lifespanwhen created through the admin API or dynamic registration; an explicit lifespan wins. The seededai-assistant-agentandinferno-backend-servicesclients carry 300 - [x] Dynamically registered backend services (keys plus
client_credentials) authenticate withfederated-jwtbound to the proxy signing IdP, like admin-created ones, so the proxy-signed assertion is accepted. Both paths build these attributes throughclientSecurityAttributes(backend/src/routes/admin/smart-apps/client-config.ts) - [x] A registration with
grant_types: ["client_credentials"]needs no redirect URI and gets no authorization code flow and no refresh token grant - [x]
system/scopes, enforced on every FHIR request;$exportrequires one (backend/src/lib/fhir-operation-policy.ts)
User-access Brands
- [x] Brand Bundle at
/branding.json(backend/src/lib/brand-bundle.ts, resources inbackend/src/lib/brand-resources.ts) - [x]
Bundle.timestampis the time the content last changed, and every entry carriesmeta.lastUpdated. A content hash per resource is recorded in the admin-config store (PostgreSQL when configured), and a timestamp moves only when its hash does, so rebuilds, restarts and other nodes report the same time - [x] Weak
ETagderived from the content, stable across rebuilds.If-None-Matchis compared weakly against every listed tag and*; the304carriesETag,Cache-ControlandLast-Modified - [x]
user_access_brand_identifierin.well-known/smart-configurationand the primary Organization's identifier come from the same runtime brand config. An organization brand is identified by its ownidentifierorwebsite, or not at all, and an identifier that repeats one already in the bundle is dropped, so the advertised identifier matches exactly one Organization - [x] Customer-supplied identifiers: both
system(defaulturn:ietf:rfc:3986) andvalueare configurable throughBRAND_IDENTIFIER_SYSTEM/BRAND_IDENTIFIERand the admin branding routes - [x] Organization entries with the
organization-brand(logo and its license) andorganization-portalextensions, fromBRAND_*settings and the admin branding routes (backend/src/routes/admin/branding.ts).brandBundleis not emitted, since it would only point at this same bundle - [x] More than one portal:
additionalPortals(orBRAND_ADDITIONAL_PORTALS) adds anorganization-portalextension per portal, each listing its own endpoints - [x] Must-Support address, including
address.line(addressLine, one line per row), published when any address part is set - [x] One Endpoint per registered FHIR server, named after the server, with
endpoint-fhir-version, connection typehl7-fhir-rest, and acontactURL where developers configure access (developerUrl/BRAND_DEVELOPER_URL, default the proxy's app registration docs) - [x] One Organization per Keycloak Organization, linked by
partOf, listing only the endpoints that organization may use - [x] Bundle validated against the IG's constraints when built
Choosing a provider from the Brand Bundle ("Connect to my records") is the patient app's job, not the server's. Inside the organization, useBranding in shared-ui and the AIHR portal read the bundle the server advertises.
Task-based launch (SMART 2.1, experimental)
POST /auth/launch/task (backend/src/routes/auth/oauth/task-launch.ts, see Task launch) acts on a launch Task, given inline or as a reference that is read through the FHIR proxy with the caller's token, so scope, consent and tenant rules apply.
- [x]
task-ehr-launchandtask-standalone-launchvalidated against their profiles:Task.codefromsmart-codes, exactly onesmartonfhir-applicationinput, at most onesmartonfhir-appcontext(backend/src/lib/launch-task-profile.ts) - [x] EHR launch: a launch code minted from
Task.forandTask.encounterunder the same authorization asPOST /auth/launch(backend/src/routes/auth/oauth/launch-mint.ts), answered with the app launch URL carryingissandlaunch - [x] Standalone launch: the app launch URL with
issonly. A standalone launch has no launch parameter and so carries no context: anappContextinput is refused with400, andTask.forandTask.encounterare listed incontext_not_carried - [x] EHR launch: the launch code is bound to the server's FHIR base, and an encounter is checked to belong to the patient
- [x] The application URL must be the launch URL of exactly one registered SMART app, from its
launch_urlor, for a dynamically registered app, one of itslaunch_uris, and the launch code is bound to that client - [x]
appContextreturned as is in the token response, as for a CDS Hooks Link;POST /auth/launchaccepts it too - [x] Context references must point at the server the launch is for;
cancelledandentered-in-errorTasks are refused with409 - [ ] Task
statusandoutputafter launch: the specification defines none, so the Task is left unchanged
App State (SMART 2.1, experimental)
The proxy hosts the App State endpoint itself at {BASE_URL}/app-state, apart from the upstream FHIR servers, which it may not be able to write to (backend/src/lib/app-state/, routes in backend/src/routes/app-state.ts, see App State).
- [x]
POST /Basic,PUTandDELETE /Basic/[id],GET /Basic?code=&subject=, andsubject:missing=truefor global state; a CapabilityStatement at/app-state/metadatainstantiatingsmart-app-state-server - [x] Search values percent-decoded only, so a literal
+in a code is a plus; one code per search; an empty searchset carries noentry - [x]
smart-app-state-basicrules: exactly one Coding, noidormeta.versionIdon create, an absolute subject of a permitted type on a FHIR server the proxy serves, top-levelvalueStringextensions only, and a 256 KB body unless raised withAPP_STATE_MAX_RESOURCE_BYTES - [x]
ETagon every response;If-Matchrequired on update and delete,400when it is missing (FHIR R4 http) and412for a stale version or a changed code or subject - [x] Every request about a deleted id is a failed precondition (
412), checked before the body orIf-Match - [x] Per-app, per-code permissions in the spec's six sets (query or modify, for the in-context patient, the in-context user, or global state) through
appStatePermissionson SMART apps;appStateCodesandappStateGlobalCodeskeep their meaning as shorthands - [x] The subject bound to the in-context patient or user on the FHIR server the token's context belongs to (its
aud, an absolutefhirUser, or the only server); refused in a multi-server deployment when that server cannot be determined - [x]
patient/Basic.*oruser/Basic.*scopes, including?code=narrowing - [x] The per-(code, subject) limit holds under concurrent creates (one transaction behind an advisory lock in PostgreSQL)
- [x] Stored in PostgreSQL (
smart_app_state) when a database is configured, else inDATA_DIR/app-state.json; a database outage fails the request rather than splitting state across two stores - [ ] State codes for apps registered by metadata document (CIMD), which have no Keycloak record to hold them
Operational notes
These are not SMART requirements, but they decide how far the boxes above hold in a deployment.
- Rate limiting on the credential endpoints (
/auth/token,/auth/device,/auth/introspect,/auth/revoke,/auth/register) is off unlessAUTH_RATE_LIMIT_PER_MINUTEis set. It runs per route before body validation, and keys on the client address takenTRUSTED_PROXY_HOPSentries from the right ofX-Forwarded-For(backend/src/lib/client-ip.ts), so a client cannot pick its own bucket. The audit and OAuth event logs record the same address. - The
jtireplay cache and the launch-context store are shared through PostgreSQL when a database is configured; thejtiis spent with a singleINSERT ... ON CONFLICTand kept until the assertion'sexpplus the 30-second clock skew. Launch codes are spent in the same table until they expire. So is the list of access tokens revoked through/auth/revoke, which every node checks in memory and refreshes from the database every five seconds. Without a database all three are per-process. The rate limiter counts per process in either case. - Backend tests run in-process against mocked Keycloak and FHIR upstreams; the Postgres stores above are also tested against a real Postgres in CI (
backend/test/shared-state-postgres.test.ts). The Inferno runs above are the only end-to-end check against a live Keycloak and HAPI FHIR server.