Medplum authentication flow (Perci backend)¶
This doc explains how Medplum user-authenticated requests are wired in the backend. It covers context propagation, token exchange, caching, and how middleware prepares a request so downstream code can call Medplum as the signed-in user.
Components¶
- AsyncLocalStorage context (
MedplumContext.ts) - Stores per-request data: the Descope session token (used as the cache key), the user-authenticated
MedplumClient, and the Descope project type ('member'or'clinical'). -
Helpers:
runWithMedplumContext(cb, sessionToken, descopeProjectType, userClient?)seeds ALS for a callback.getMedplumSessionToken()/getMedplumUserClient()/getDescopeProjectType()read from ALS.setMedplumUserClient(client)sets the user client in ALS for the current request.
-
Client ensure + token exchange (
ensureMedplumUserClient.ts) ensureMedplumUserClient(sessionTokenOverride?)- If a user client already exists in ALS, returns it.
- Otherwise resolves the session token (override or from ALS), checks the in-memory cache, then performs a token exchange if needed.
- On success, stores the
MedplumClientin ALS and the cache.
- Token exchange
- Uses Medplum OAuth Token Exchange (
grant_type = urn:ietf:params:oauth:grant-type:token-exchange), with a wrapped Descope session token as thesubject_token(typeaccess_token). - The token is wrapped with project type metadata (
'member'or'clinical') and HMAC-SHA256 signature usingwrapDescopeTokenbefore sending to Medplum. - Format:
<project_type>:<base64url_signature>:<original_token> - This allows the unified userinfo endpoint to determine which Descope project to validate against.
- Configured by env vars (see below); credentials are sent in the body (client ID + secret).
- Uses Medplum OAuth Token Exchange (
-
Caching & de-dupe
MedplumTokenCachecachesMedplumClientper original session token (not wrapped) with expiry based onexpires_in.inFlightmap prevents concurrent exchanges for the same token (keyed by SHA-256 of the original session token).
-
Convenience wrapper
-
runWithMedplumUser(sessionToken, projectType, cb)- Seeds ALS with the session token and project type, calls
ensureMedplumUserClient()(reading token from ALS), and then runs the callback. - Throws if a user client cannot be initialized.
- The
projectTypeparameter ('member'or'clinical') determines which Descope API to use for token validation at the userinfo endpoint.
- Seeds ALS with the session token and project type, calls
-
Admin Medplum client (
adminMedplumClient/MedplumSystemApi) adminMedplumClientis aLazy<MedplumClient>using client-credentials authentication (no user token), used for system-driven flows such as clinician/member signup and GP organisation creation.MedplumSystemApiis the service-layer wrapper built on top ofadminMedplumClient, exposing the same service interface asMedplumApi.- Both
MedplumApiandMedplumSystemApishare the same base URL and client ID;MedplumSystemApialways uses the client secret directly whileMedplumApiuses the per-request user client (falling back toadminMedplumClientwhen no user client is in ALS).
Middleware usage¶
Clinician (bff-clinical/middleware/descopeClinicianMiddleware.ts)¶
- Validates Descope session token and builds
authUser. - Stores auth info in
res.locals. - If the user has a
medplumId, it wraps the request handling inrunWithMedplumUser(sessionToken, 'clinical', execute)so Medplum calls run with a user client in ALS. - Validates required permissions (if provided) using Medplum ID + Descope permissions.
- Invokes the handler; downstream code can call
getMedplumUserClient()to get the user-authenticated client.
Member (bff/middleware/descopeMemberMiddleware.ts)¶
- Validates Descope session token and builds
authUser. - If the user has a
medplumId, wraps the handler inrunWithMedplumUser(sessionToken, 'member', execute). - Handler runs with the client available via ALS when applicable.
Downstream usage¶
Within any code executed under runWithMedplumUser (or after a manual ensureMedplumUserClient inside runWithMedplumContext):
const client = getMedplumUserClient();- Use
clientto call Medplum APIs as the authenticated user (on-behalf-of semantics).
If no Medplum linkage exists for the user, middlewares skip the wrapper and downstream code should avoid user-scoped Medplum calls.
Environment variables¶
From EnvVars / Secrets used in ensureMedplumUserClient:
MEDPLUM_BASE_URL– Medplum base URLMEDPLUM_CLIENT_IDMedplumClientSecret(from Secrets)
adminMedplumClient / MedplumSystemApi reuse the same MEDPLUM_BASE_URL and MEDPLUM_CLIENT_ID with MedplumClientSecret for client-credential calls.
Unified user info endpoint (API)¶
- A single unified endpoint at
GET /v1/descope/userinfois exposed via the API function: - Location:
apps/perci-platform-backend/functions/src/api/descope/descopeRouter.ts - The endpoint expects a
Bearer <wrapped_token>header containing the token wrapped bywrapDescopeToken. - Token unwrapping and validation:
- Extracts project type and original session token from the wrapped format
- Verifies HMAC-SHA256 signature to prevent tampering
- Routes to the appropriate Descope API (
DescopeMemberApiorDescopeClinicalApi) based on project type - Validates the session with the correct Descope project
- Returns a minimal OIDC-style payload:
Note:
{ "sub": "<medplumId>", "email": "<user email>", "email_verified": true, "name": "<given + family name or email>" }subis the Medplum profile ID (themedplumIdstored in Descope's custom attributes), not the Descope user ID. Medplum requiressubto identify its own user record. - Medplum web clients are configured to call this endpoint as their userinfo URL during initialization, so the frontend Medplum SDK can resolve the signed-in user before making on-behalf-of requests.
- The token wrapping ensures both member and clinician tokens can be validated through a single endpoint, as Medplum only allows configuring one userinfo URL per client.
Error handling¶
- Token exchange failures are logged and surfaced as errors; the helper throws to let middleware return an auth error path.
- If
ensureMedplumUserClientcannot establish a client (e.g., missing token), it returnsundefined;runWithMedplumUserturns that into an error.
Why ALS + helper?¶
- ALS avoids threading the session token/client through every call.
runWithMedplumUserprovides a single entry point that: seeds context, de-dupes token exchange, caches the client, and makes it available to all downstream functions during the request lifetime.