Skip to main content
Glama
keys.ts9.39 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { OAuthSigningAlgorithm, Operator } from '@medplum/core'; import type { JsonWebKey } from '@medplum/fhirtypes'; import type { JWK, JWSHeaderParameters, JWTPayload, JWTVerifyOptions, KeyLike } from 'jose'; import { exportJWK, generateKeyPair, importJWK, jwtVerify, SignJWT } from 'jose'; import { randomBytes } from 'node:crypto'; import type { MedplumServerConfig } from '../config/types'; import { getSystemRepo } from '../fhir/repo'; import { globalLogger } from '../logger'; export interface MedplumBaseClaims extends JWTPayload { /** * Client application ID. * This is a reference a ClientApplication resource. */ client_id?: string; /** * Login ID. * This is the UUID of the Login resource. */ login_id: string; } export interface MedplumIdTokenClaims extends MedplumBaseClaims { name?: string; fhirUser?: string; nonce: string; } export interface MedplumAccessTokenClaims extends MedplumBaseClaims { /** * OpenID username. Same as JWTPayload.sub. */ username: string; /** * OpenID scope(s). Space delimited string. * SMART-on-FHIR scopes. */ scope: string; /** * FHIR profile or role. * Qualified reference to the FHIR resource. * For example, "Patient/123" or "Practitioner/456". */ profile: string; /** * User email address. * Included when the 'email' scope is requested and the user is a User resource. */ email?: string; } export interface MedplumRefreshTokenClaims extends MedplumBaseClaims { /** * Refresh secret. * Due to the powerful nature of a refresh token, * we use an additional random secret for security. */ refresh_secret: string; } /* * Signing algorithms. * * For the first 4 years of this project, we only supported RS256: * RS256 (RSA Signature with SHA-256): An asymmetric algorithm, which means that there are two keys: * one public key and one private key that must be kept secret. The server has the private key used to * generate the signature, and the consumer of the JWT retrieves a public key from the metadata * endpoints provided by the server and uses it to validate the JWT signature. * * Due to customer requests for FAPI 2 compliance, we are now expanding to support ES256: * ES256 (ECDSA using P-256 and SHA-256): An asymmetric algorithm using elliptic curve cryptography. * Like RS256, it uses a public/private key pair, but offers better performance characteristics, * smaller key sizes, and faster signature generation while providing equivalent security to 2048-bit RSA. * * To support existing customers and deployments, we will continue to support existing keys using RS256. * All new keys will use ES256 by default. * * Note: AWS Cognito uses RS256. Auth0 supports RS256, HS256, and PS256 options. */ const PREFERRED_ALG = OAuthSigningAlgorithm.ES256; const LEGACY_DEFAULT_ALG = OAuthSigningAlgorithm.RS256; const DEFAULT_ACCESS_LIFETIME = '1h'; const DEFAULT_REFRESH_LIFETIME = '2w'; let issuer: string | undefined; const publicKeys: Record<string, KeyLike> = {}; const jwks: { keys: JWK[] } = { keys: [] }; let jsonWebKey: JsonWebKey | undefined; let signingKey: KeyLike | undefined; export async function initKeys(config: MedplumServerConfig): Promise<void> { issuer = undefined; jsonWebKey = undefined; signingKey = undefined; jwks.keys = []; if (!config) { throw new Error('Invalid server configuration'); } issuer = config.issuer; if (!issuer) { throw new Error('Missing issuer'); } const systemRepo = getSystemRepo(); const searchResult = await systemRepo.searchResources<JsonWebKey>({ resourceType: 'JsonWebKey', filters: [{ code: 'active', operator: Operator.EQUALS, value: 'true' }], }); let jsonWebKeys: JsonWebKey[] | undefined; if (searchResult.length > 0) { globalLogger.info(`Loaded ${searchResult.length} key(s) from the database`); jsonWebKeys = searchResult; } else { // Generate a key pair // https://github.com/panva/jose/blob/HEAD/docs/functions/util_generate_key_pair.generatekeypair.md globalLogger.info('No keys found. Creating new key...'); const keyResult = await generateKeyPair(PREFERRED_ALG); const jwk = await exportJWK(keyResult.privateKey); const createResult = await systemRepo.createResource<JsonWebKey>({ resourceType: 'JsonWebKey', active: true, alg: PREFERRED_ALG, ...jwk, } as JsonWebKey); jsonWebKeys = [createResult]; } // Convert our JsonWebKey array to JWKS for (const jwk of jsonWebKeys) { jwk.alg ??= LEGACY_DEFAULT_ALG; const publicKey: JWK = { kid: jwk.id, alg: jwk.alg, kty: jwk.kty, use: 'sig', }; if (jwk.alg === OAuthSigningAlgorithm.ES256) { publicKey.x = jwk.x; publicKey.y = jwk.y; publicKey.crv = jwk.crv as string; } else { publicKey.e = jwk.e; publicKey.n = jwk.n; } // Add to the JWKS (JSON Web Key Set) // This will be publicly available at /.well-known/jwks.json jwks.keys.push(publicKey); // Convert from JWK to PKCS and add to the collection of public keys publicKeys[jwk.id as string] = (await importJWK(publicKey)) as KeyLike; } // Use the first key as the signing key jsonWebKey = jsonWebKeys[0]; signingKey = (await importJWK({ ...(jsonWebKey as JWK), use: 'sig', })) as KeyLike; } /** * Returns the current set of active public keys. * These keys can be used to verify a JWT. * @returns Array of public keys. */ export function getJwks(): { keys: JWK[] } { return jwks; } /** * Returns the current signing key. * @returns The current signing key. */ export function getSigningKey(): KeyLike { return signingKey as KeyLike; } /** * Generates a secure random string suitable for a client secret or refresh secret. * @param size - Size of the secret in bytes. 16 recommended for auth codes. 32 recommended for client and refresh secrets. * @returns Secure random string. */ export function generateSecret(size: number): string { return randomBytes(size).toString('hex'); } /** * Generates an ID token JWT. * @param claims - The ID token claims. * @returns A well-formed JWT that can be used as an ID token. */ export function generateIdToken(claims: MedplumIdTokenClaims): Promise<string> { return generateJwt('1h', claims); } /** * Generates an access token JWT. * @param claims - The access token claims. * @param options - Optional parameters. * @param options.additionalClaims - Any additional custom claims. * @param options.lifetime - Access token duration. * @returns A well-formed JWT that can be used as an access token. */ export function generateAccessToken( claims: MedplumAccessTokenClaims, options?: { additionalClaims?: Record<string, string | number>; lifetime?: string } ): Promise<string> { const duration = options?.lifetime ?? DEFAULT_ACCESS_LIFETIME; return generateJwt(duration, options?.additionalClaims ? { ...claims, ...options.additionalClaims } : claims); } /** * Generates a refresh token JWT. * @param claims - The refresh token claims. * @param lifetime - The refresh token duration. * @returns A well-formed JWT that can be used as a refresh token. */ export function generateRefreshToken(claims: MedplumRefreshTokenClaims, lifetime?: string): Promise<string> { const duration = lifetime ?? DEFAULT_REFRESH_LIFETIME; return generateJwt(duration, claims); } /** * Generates a JWT. * @param exp - Expiration time resolved to a time span. * @param claims - The key/value pairs to include in the payload section. * @returns Promise to generate and sign the JWT. */ async function generateJwt(exp: string, claims: JWTPayload): Promise<string> { if (!jsonWebKey || !signingKey || !issuer) { throw new Error('Signing key not initialized'); } const regex = /^\d+[smhdwy]$/; if (!regex.test(exp)) { throw new Error('Invalid token duration'); } return new SignJWT(claims) .setProtectedHeader({ alg: jsonWebKey.alg ?? LEGACY_DEFAULT_ALG, kid: jsonWebKey.id, typ: 'JWT', }) .setIssuedAt() .setNotBefore(new Date()) .setIssuer(issuer) .setAudience(claims.client_id as string) .setExpirationTime(exp) .sign(signingKey); } /** * Decodes and verifies a JWT. * @param token - The jwt token / bearer token. * @returns Returns the decoded claims on success. */ export async function verifyJwt(token: string): Promise<{ payload: JWTPayload; protectedHeader: JWSHeaderParameters }> { if (!issuer) { throw new Error('Signing key not initialized'); } const verifyOptions: JWTVerifyOptions = { issuer, algorithms: [OAuthSigningAlgorithm.ES256, OAuthSigningAlgorithm.RS256], }; return jwtVerify(token, getKeyForHeader, verifyOptions); } /** * Returns a public key to verify a JWT. * Implements the "JWTVerifyGetKey" interface for jwtVerify. * @param protectedHeader - The JWT protected header. * @returns The public key. */ function getKeyForHeader(protectedHeader: JWSHeaderParameters): KeyLike { const kid = protectedHeader.kid; if (!kid) { throw new Error('Missing kid header'); } const result = publicKeys[kid]; if (!result) { throw new Error('Key not found'); } return result; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server