Fermer

juillet 30, 2025

Implémentation de l’authentification Smart Configurable sur FHIR avec Node.js

Implémentation de l’authentification Smart Configurable sur FHIR avec Node.js


Introduction

Dans le paysage rapide de la technologie de la santé, l’interopérabilité et la sécurité sont essentielles. Smart on FHIR (applications médicales substituables, technologies réutilisables sur les ressources d’interopérabilité des soins de santé rapide) est devenue une norme robuste pour créer des applications qui s’intègrent parfaitement aux systèmes de dossiers de santé électroniques.

Ce blog, qui est destiné aux développeurs, créant des applications de soins de santé, a couvert le cycle de vie complet de la mise en œuvre d’un système d’authentification SMART configurable sur FHIR à l’aide de Node.js et Express, avec le support pour EPIC et Cerner EHR.

Qu’est-ce que Fhir?

FHIR (prononcé comme Fire) est une norme développée par HL7 pour simplifier l’échange de données sur les soins de santé entre différents systèmes et applications

Caractéristiques clés:

  • Brise les données sur les soins de santé en petits composants modulaires appelés ressources (par exemple, patient, rencontre)
  • Définit la structure, le contenu et la sémantique standard pour ceux-ci.
  • Expose les ressources de santé via des API RESTful.

Exemple de ressource simple des patients:

{
  "resourceType": "Patient",
  "id": "example",
  "name": [{"family": "Sheikh", "given": ["Mavin"]}],
  "gender": "male",
  "birthDate": "1980-26-26"
}

Qu’est-ce que Smart sur Fhir?

Smart on FHIR est une norme ouverte qui permet aux applications tierces de se connecter avec des systèmes de soins de santé en utilisant les API FHIR REST et les flux d’autorisation OAuth 2.0 standardisés.

Types de lancement des applications intelligentes

Smart on Fhir définit trois façons de lancer l’application:

  • Lancement autonome: Lancé directement par l’utilisateur en dehors du DSE.
  • DSE (également appelé le lancement intégré): Lancé dans une application de DSE.
  • Systèmes backend: Pour les applications sans interaction utilisateur ou patient direct (Dans ce blog, nous ne discuterons que des deux premiers types de lancement)

Séquence de lancement autonome

  • Enregistrez l’application auprès du serveur FHIR pour obtenir un ID client (par exemple, via Épique ou Cerner).
  • L’utilisateur visite votre application directement en dehors du DSE.
  • Obtenez une configuration intelligente bien connue du serveur FHIR (également appelé instruction CONFORMANCE).
  • Redirigez l’utilisateur vers le point de terminaison Authorize du serveur FHIR.
  • FHIR Server authentifie l’utilisateur et redirige vers votre URL de rappel avec un code d’autorisation.
  • Échangez le code au point de terminaison du jeton pour obtenir un jeton d’accès.

Lancement autonome

Séquence de lancement autonome

Séquence de lancement du DSE (intégrée)

La séquence de lancement du DSE (intégrée) est la même que celle de ce qui est autonome, sauf:

  • L’application est lancée à partir de la DSE.
  • DSE passe un contexte de lancement et ISS (URL de base).

Séquence de lancement intégrée

Séquence de lancement intégrée

Implémentation étape par étape

Étape 1: Enregistrez votre application

Enregistrez votre application avec la plate-forme FHIR souhaitée (EPIC, Cerner, etc.) pour obtenir des informations d’identification des clients et configurer la redirection URI.

Étape 2: Configuration de l’application Express (app.ts)

import express, { Request, Response, NextFunction } from 'express';
import authRoutes from './routes/auth';
import { appConfig } from './config';

const app = express();
app.use(express.json());

app.use('/auth', authRoutes);
app.listen(appConfig.port, () => {
 console.log(`Auth Server running at ${appConfig.origin}`);
});

Étape 3: Définir les routes (routes / auth.ts)

import express from 'express';
import {
 standaloneLaunch,
 standaloneLaunchCallback,
 embeddedLaunch,
 embeddedLaunchCallback,
} from '../controllers/auth';

const router = express.Router();
router.get('/standalone/:provider', standaloneLaunch);
router.get('/callback', standaloneLaunchCallback);
router.get('/embedded', embeddedLaunch);
router.get('/embeddedCallback', embeddedLaunchCallback);
export default router;

Étape 4: Ajouter un fichier de configuration dans le dossier racine (config.ts)

import { EhrProvider } from './enums/ehrProvider'
import { EhRAuthConfig, AppConfig } from './types';
require('dotenv').config();

export const appConfig: AppConfig = {
   port: process.env.PORT! || '3000',
   host: process.env.HOST!,
   origin: `${process.env.HOST}:${process.env.PORT}`,
}

export const ehrAuthConfig: Record<EhrProvider, EhRAuthConfig> = {
   [EhrProvider.EPIC]: {
       authorizationUrl: process.env.EPIC_AUTH_URL!,
       tokenUrl: process.env.EPIC_TOKEN_URL!,
       clientId: process.env.EPIC_CLIENT_ID!,
       standaloneRedirectUrl: appConfig.origin + '/auth/callback',
       embeddedRedirectUrl: appConfig.origin + '/auth/embeddedCallback',
       fhirApiBase: process.env.EPIC_FHIR_API_BASE!,
       scope: 'openid profile user/Patient.read patient/MedicationRequest.write'
   },
   [EhrProvider.CERNER]: {
       authorizationUrl: process.env.CERNER_AUTH_URL!,
       tokenUrl: process.env.CERNER_TOKEN_URL!,
       clientId: process.env.CERNER_CLIENT_ID!,
       standaloneRedirectUrl: appConfig.origin + '/auth/callback',
       embeddedRedirectUrl: appConfig.origin + '/auth/embeddedCallback',
       fhirApiBase: process.env.CERNER_FHIR_API_BASE!,
       scope: 'openid profile user/Patient.read'
   }
}

Étape 5: Variables d’environnement (.env)

# App

PORT=3000
HOST=http://localhost

# EPIC AUTH CONFIG

EPIC_AUTH_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/authorize
EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
EPIC_CLIENT_ID=<your-client-id>
EPIC_FHIR_API_BASE=https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4

# CERNER AUTH CONFIG

CERNER_AUTH_URL=https://authorization.cerner.com/tenants/ec2458f2-1e24-41c8-b71b-0e701af7583d/protocols/oauth2/profiles/smart-v1/personas/provider/authorize
CERNER_TOKEN_URL=https://authorization.cerner.com/tenants/ec2458f2-1e24-41c8-b71b-0e701af7583d/protocols/oauth2/profiles/smart-v1/token
CERNER_CLIENT_ID=<your-client-id>
CERNER_FHIR_API_BASE=https://fhir-ehr-code.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d

Étape 4: Ajouter des contrôleurs (contrôleurs / auth.ts)

import { Request, Response } from 'express';
import { ehrAuthConfig } from '../config';
import { EhrProvider, HttpStatusCode, HttpMethod } from '../enums';
import { getWellKnownSmartConfiguration } from '../services/fhir.service';

export const standaloneLaunch = (req: Request, res: Response): void => {
   const provider = req.params.provider as EhrProvider;
   if (!provider) {
       console.log('Missing emr param')
       res.status(HttpStatusCode.BAD_REQUEST).send('Missing emr param');
       return
   }

   const authConfig = ehrAuthConfig[provider]

   try {
       const authParams = new URLSearchParams({
           response_type: "code",
           client_id: authConfig.clientId,
           redirect_uri: authConfig.standaloneRedirectUrl,
           scope: authConfig.scope,
           aud: authConfig.fhirApiBase,
           state: provider,
       });
       const redirectUrl = `${authConfig.authorizationUrl}?${authParams.toString()}`;
       res.redirect(redirectUrl);
   } catch (error) {
       console.error('Error:', (error as Error).message);
       res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send('Internal server error');
   }
};

export const standaloneLaunchCallback = async (req: Request, res: Response): Promise<void> => {
   const { code, state } = req.query;
   const authConfig = ehrAuthConfig[state as EhrProvider]
   try {
       const params = new URLSearchParams({
           grant_type: 'authorization_code',
           code,
           redirect_uri: authConfig.standaloneRedirectUrl,
           client_id: authConfig.clientId,
       });

       // Exchanges  Auth Code for an Access Token
       const response = await fetch(authConfig.tokenUrl, {
           method: HttpMethod.POST,
           headers: {
               'Content-Type': 'application/x-www-form-urlencoded',
           },
           body: params.toString(),
       });

       if (!response.ok) {
           const errorData = await response.text();
           console.error('Token exchange failed:', errorData);
           res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send('Token exchange failed');
           return;
       }
       const data = await response.json()
       const { access_token, token_type, id_token, scope } = data;
       res.json({ access_token, token_type, id_token, scope });
   } catch (error) {
       res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send('Internal server error');
   }
}

function getEhrProviderByIssuer(fhirApiBase: string): EhrProvider {
   return Object.entries(ehrAuthConfig).find(
       ([, config]) => config.fhirApiBase === fhirApiBase
   )?.[0] as EhrProvider;
}

let tokenUrl: string

export const embeddedLaunch = async (req: Request, res: Response): Promise<any> => {
   const fhirServerUrl: any = req.query.iss!;
   const launchContext: any = req.query.launch!;
   const ehrProvider = getEhrProviderByIssuer(fhirServerUrl)
   const authConfig = ehrAuthConfig[ehrProvider]

   if (!fhirServerUrl || !launchContext) {
       console.log('Missing iss or launch parameter')
       return res.status(HttpStatusCode.BAD_REQUEST).send('Missing iss or launch parameter.');
   }

   try {
       const smartConfig = await getWellKnownSmartConfiguration(fhirServerUrl)
       const authorizeUrl = smartConfig.authorization_endpoint;
       tokenUrl = smartConfig.token_endpoint;

       const authParams = new URLSearchParams({
           response_type: "code",
           client_id: authConfig.clientId,
           redirect_uri: authConfig.embeddedRedirectUrl,
           scope: "launch patient/*.read",
           launch: launchContext.toString(),
           aud: fhirServerUrl.toString(),
           state: ehrProvider
       }
       const redirectUrl = `${authorizeUrl}?${authParams.toString()}`;
       res.redirect(redirectUrl);
   } catch (error) {
       res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send("Failed to launch");
   }
}

export const embeddedLaunchCallback = async (req: Request, res: Response): Promise<any> => {
   const { code, state } = req.query;
   const authConfig = ehrAuthConfig[state as EhrProvider]

   try {
       const tokenParams = new URLSearchParams({
           grant_type: 'authorization_code',
           code: code as string,
           client_id: authConfig.clientId,
           redirect_uri: authConfig.embeddedRedirectUrl,
       });
       const tokenResponse = await fetch(tokenUrl, {
           method: HttpMethod.POST,
           headers: {
               'Content-Type': 'application/x-www-form-urlencoded',
           }
           body: tokenParams.toString(),
       });

       if (!tokenResponse.ok) {
           const errorText = await tokenResponse.text();
           return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send('Error exchanging code for token');
       }
       const tokenData = await tokenResponse.json();
       const accessToken = tokenData.access_token as string;
       res.send(`Access token received! ${accessToken}`);
   } catch (err) {
       res.status(HttpStatusCode.INTERNAL_SERVER_ERROR).send('Unexpected error during token exchange');
   }
}

module.exports = {
   standaloneLaunch,
   standaloneLaunchCallback,
   embeddedLaunch,
   embeddedLaunchCallback,
};

Note: Dans le lancement autonome, le nom du fournisseur de DSE (par exemple Epic ou Cerner) est passé dans le paramètre d’état. Cela permet au gestionnaire de rappel de déterminer la configuration à utiliser lors de l’échange de code d’automne contre un jeton.

Tester votre application

Lancement autonome

Démarrez votre serveur: NPM Run Start Et essayez:
Épique: http: // localhost: 3000 / auth / autonome / epic
Cerner: http: // localhost: 3000 / auth / autonome / cerner

Vous serez redirigé vers l’écran de connexion DSE. Après avoir réussi à vous connecter, vous recevrez un jeton d’accès.

Lancement intégré

Pour simuler le lancement embarqué, utilisez le lanceur d’applications intelligent avec votre point de terminaison intégré: http: // localhost: 3000 / auth / embedded

Utilisation de jeton pour accéder aux ressources FHIR

Une fois que vous avez reçu un jeton d’accès valide, vous pouvez désormais accéder aux données protégées des patients de la base de données DSE à l’aide d’API FHIR REST. Vous devez passer le jeton d’accès dans l’en-tête d’autorisation en tant que jeton de porteur.

GET <fhir-server-url>/Patient/{id}
Authorization: Bearer Nxfve4q3H9TKs5F5vf6kRYAZqz...

Ajouter le support pour un nouveau DSE

Ajouter la prise en charge d’un autre SMART sur FHIR conforme à DSE est simple, Ajoutez simplement une autre entrée du fournisseur dans Ehrenauthconfig à l’intérieur de config.ts. Aucune logique supplémentaire requise!






Source link