Skip to content

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 MedplumClient in 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 the subject_token (type access_token).
    • The token is wrapped with project type metadata ('member' or 'clinical') and HMAC-SHA256 signature using wrapDescopeToken before 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).
  • Caching & de-dupe

    • MedplumTokenCache caches MedplumClient per original session token (not wrapped) with expiry based on expires_in.
    • inFlight map 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 projectType parameter ('member' or 'clinical') determines which Descope API to use for token validation at the userinfo endpoint.
  • Admin Medplum client (adminMedplumClient / MedplumSystemApi)

  • adminMedplumClient is a Lazy<MedplumClient> using client-credentials authentication (no user token), used for system-driven flows such as clinician/member signup and GP organisation creation.
  • MedplumSystemApi is the service-layer wrapper built on top of adminMedplumClient, exposing the same service interface as MedplumApi.
  • Both MedplumApi and MedplumSystemApi share the same base URL and client ID; MedplumSystemApi always uses the client secret directly while MedplumApi uses the per-request user client (falling back to adminMedplumClient when no user client is in ALS).

Middleware usage

Clinician (bff-clinical/middleware/descopeClinicianMiddleware.ts)

  1. Validates Descope session token and builds authUser.
  2. Stores auth info in res.locals.
  3. If the user has a medplumId, it wraps the request handling in runWithMedplumUser(sessionToken, 'clinical', execute) so Medplum calls run with a user client in ALS.
  4. Validates required permissions (if provided) using Medplum ID + Descope permissions.
  5. Invokes the handler; downstream code can call getMedplumUserClient() to get the user-authenticated client.

Member (bff/middleware/descopeMemberMiddleware.ts)

  1. Validates Descope session token and builds authUser.
  2. If the user has a medplumId, wraps the handler in runWithMedplumUser(sessionToken, 'member', execute).
  3. 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 client to 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 URL
  • MEDPLUM_CLIENT_ID
  • MedplumClientSecret (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/userinfo is 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 by wrapDescopeToken.
  • 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 (DescopeMemberApi or DescopeClinicalApi) based on project type
  • Validates the session with the correct Descope project
  • Returns a minimal OIDC-style payload:
    {
      "sub": "<medplumId>",
      "email": "<user email>",
      "email_verified": true,
      "name": "<given + family name or email>"
    }
    
    Note: sub is the Medplum profile ID (the medplumId stored in Descope's custom attributes), not the Descope user ID. Medplum requires sub to 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 ensureMedplumUserClient cannot establish a client (e.g., missing token), it returns undefined; runWithMedplumUser turns that into an error.

Why ALS + helper?

  • ALS avoids threading the session token/client through every call.
  • runWithMedplumUser provides 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.