Skip to main content
Glama
token.ts21.9 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ProfileResource, WithId } from '@medplum/core'; import { ContentType, OAuthClientAssertionType, OAuthGrantType, OAuthSigningAlgorithm, OAuthTokenType, Operator, createReference, getStatus, isJwt, normalizeErrorString, normalizeOperationOutcome, parseJWTPayload, resolveId, } from '@medplum/core'; import type { ClientApplication, Login, Project, ProjectMembership, Reference, User } from '@medplum/fhirtypes'; import type { Request, RequestHandler, Response } from 'express'; import type { JWTVerifyOptions } from 'jose'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { randomUUID } from 'node:crypto'; import { getUserConfiguration } from '../auth/me'; import { getProjectIdByClientId } from '../auth/utils'; import { getConfig } from '../config/loader'; import { getAccessPolicyForLogin } from '../fhir/accesspolicy'; import { getSystemRepo } from '../fhir/repo'; import { getTopicForUser } from '../fhircast/utils'; import type { MedplumRefreshTokenClaims } from './keys'; import { generateSecret, verifyJwt } from './keys'; import { checkIpAccessRules, getAuthTokens, getClientApplication, getClientApplicationMembership, getExternalUserInfo, hashCode, revokeLogin, timingSafeEqualStr, tryLogin, verifyMultipleMatchingException, } from './utils'; type ClientIdAndSecret = { error?: string; clientId?: string; clientSecret?: string }; type FhircastProps = { 'hub.topic': string; 'hub.url': string }; /** * Handles the OAuth/OpenID Token Endpoint. * * Implements the following authorization flows: * 1) Client Credentials - for server-to-server access * 2) Authorization Code - for user access * 3) Refresh - for "remember me" long term access * * See: https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint * @param req - The request object * @param res - The response object */ export const tokenHandler: RequestHandler = async (req: Request, res: Response): Promise<void> => { if (!req.is(ContentType.FORM_URL_ENCODED)) { res.status(400).send('Unsupported content type'); return; } const grantType = req.body.grant_type as OAuthGrantType; if (!grantType) { sendTokenError(res, 'invalid_request', 'Missing grant_type'); return; } switch (grantType) { case OAuthGrantType.ClientCredentials: await handleClientCredentials(req, res); break; case OAuthGrantType.AuthorizationCode: await handleAuthorizationCode(req, res); break; case OAuthGrantType.RefreshToken: await handleRefreshToken(req, res); break; case OAuthGrantType.TokenExchange: await handleTokenExchange(req, res); break; default: sendTokenError(res, 'invalid_request', 'Unsupported grant_type'); } }; /** * Handles the "Client Credentials" OAuth flow. * See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 * @param req - The HTTP request. * @param res - The HTTP response. */ async function handleClientCredentials(req: Request, res: Response): Promise<void> { const { clientId, clientSecret, error } = await getClientIdAndSecret(req); if (error) { sendTokenError(res, 'invalid_request', error); return; } if (!clientId) { sendTokenError(res, 'invalid_request', 'Missing client_id'); return; } if (!clientSecret) { sendTokenError(res, 'invalid_request', 'Missing client_secret'); return; } const systemRepo = getSystemRepo(); let client: WithId<ClientApplication>; try { client = await systemRepo.readResource<ClientApplication>('ClientApplication', clientId); } catch (_err) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } if (client.status && client.status !== 'active') { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } if (!(await validateClientIdAndSecret(res, client, clientSecret))) { return; } const membership = await getClientApplicationMembership(client); if (!membership) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } const project = await systemRepo.readReference(membership.project as Reference<Project>); const scope = (req.body.scope || 'openid') as string; const login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'client', user: createReference(client), client: createReference(client), membership: createReference(membership), authTime: new Date().toISOString(), granted: true, scope, remoteAddress: req.ip, userAgent: req.get('User-Agent'), }); // TODO: build full AuthState object, including on-behalf-of try { const userConfig = await getUserConfiguration(systemRepo, project, membership); const accessPolicy = await getAccessPolicyForLogin({ project, login, membership, userConfig }); await checkIpAccessRules(login, accessPolicy); } catch (err) { sendTokenError(res, 'invalid_request', normalizeErrorString(err)); return; } await sendTokenResponse(res, login, client); } /** * Handles the "Authorization Code Grant" flow. * See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1 * @param req - The HTTP request. * @param res - The HTTP response. */ async function handleAuthorizationCode(req: Request, res: Response): Promise<void> { const { clientId, clientSecret, error } = await getClientIdAndSecret(req); if (error) { sendTokenError(res, 'invalid_request', error); return; } const code = req.body.code; if (!code) { sendTokenError(res, 'invalid_request', 'Missing code'); return; } const systemRepo = getSystemRepo(); const searchResult = await systemRepo.search({ resourceType: 'Login', filters: [ { code: 'code', operator: Operator.EQUALS, value: code, }, ], }); if (!searchResult.entry || searchResult.entry.length === 0) { sendTokenError(res, 'invalid_request', 'Invalid code'); return; } const login = searchResult.entry[0].resource as WithId<Login>; if (clientId && login.client?.reference !== 'ClientApplication/' + clientId) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } if (!login.membership) { sendTokenError(res, 'invalid_request', 'Invalid profile'); return; } if (login.granted) { await revokeLogin(login); sendTokenError(res, 'invalid_grant', 'Token already granted'); return; } if (login.revoked) { sendTokenError(res, 'invalid_grant', 'Token revoked'); return; } let client: ClientApplication | undefined; try { if (clientId) { client = await getClientApplication(clientId); } else if (login.client) { client = await getClientApplication(resolveId(login.client) as string); } } catch (_err) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } if (!client?.pkceOptional) { if (login.codeChallenge) { const codeVerifier = req.body.code_verifier; if (!codeVerifier) { sendTokenError(res, 'invalid_request', 'Missing code verifier'); return; } if (!verifyCode(login.codeChallenge, login.codeChallengeMethod as string, codeVerifier)) { sendTokenError(res, 'invalid_request', 'Invalid code verifier'); return; } } else { sendTokenError(res, 'invalid_request', 'Missing verification context'); return; } } else if (clientSecret) { if (!(await validateClientIdAndSecret(res, client, clientSecret))) { return; } } await sendTokenResponse(res, login, client); } /** * Handles the "Refresh" flow. * See: https://datatracker.ietf.org/doc/html/rfc6749#section-6 * @param req - The HTTP request. * @param res - The HTTP response. */ async function handleRefreshToken(req: Request, res: Response): Promise<void> { const refreshToken = req.body.refresh_token; if (!refreshToken) { sendTokenError(res, 'invalid_request', 'Invalid refresh token'); return; } let claims: MedplumRefreshTokenClaims; try { claims = (await verifyJwt(refreshToken)).payload as MedplumRefreshTokenClaims; } catch (_err) { sendTokenError(res, 'invalid_request', 'Invalid refresh token'); return; } const systemRepo = getSystemRepo(); const login = await systemRepo.readResource<Login>('Login', claims.login_id); if (login.refreshSecret === undefined || !claims.refresh_secret) { // This token does not have a refresh available sendTokenError(res, 'invalid_request', 'Invalid refresh token'); return; } if (login.revoked) { sendTokenError(res, 'invalid_grant', 'Token revoked'); return; } // Use a timing-safe-equal here so that we don't expose timing information which could be // used to infer the secret value if (!timingSafeEqualStr(login.refreshSecret, claims.refresh_secret)) { sendTokenError(res, 'invalid_request', 'Invalid token'); return; } const authHeader = req.headers.authorization; if (authHeader) { if (!authHeader.startsWith('Basic ')) { sendTokenError(res, 'invalid_request', 'Invalid authorization header'); return; } const base64Credentials = authHeader.split(' ')[1]; const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); const [clientId, clientSecret] = credentials.split(':'); if (clientId !== resolveId(login.client)) { sendTokenError(res, 'invalid_grant', 'Incorrect client'); return; } if (!clientSecret) { sendTokenError(res, 'invalid_grant', 'Incorrect client secret'); return; } } let client: ClientApplication | undefined; if (login.client) { const clientId = resolveId(login.client) ?? ''; try { client = await systemRepo.readResource<ClientApplication>('ClientApplication', clientId); } catch (_err) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } } // Refresh token rotation // Generate a new refresh secret and update the login const updatedLogin = await systemRepo.updateResource<Login>({ ...login, refreshSecret: generateSecret(32), remoteAddress: req.ip, userAgent: req.get('User-Agent'), }); await sendTokenResponse(res, updatedLogin, client); } /** * Handles the "Exchange" flow. * See: https://datatracker.ietf.org/doc/html/rfc8693 * @param req - The HTTP request. * @param res - The HTTP response. * @returns Promise to complete. */ async function handleTokenExchange(req: Request, res: Response): Promise<void> { return exchangeExternalAuthToken( req, res, req.body.client_id, req.body.subject_token, req.body.subject_token_type, req.body.membership_id ); } /** * Exchanges an existing token for a new set of tokens. * See: https://datatracker.ietf.org/doc/html/rfc8693 * @param req - The HTTP request. * @param res - The HTTP response. * @param clientId - The client application ID. * @param subjectToken - The subject token. Only access tokens are currently supported. * @param subjectTokenType - The subject token type as defined in Section 3. Only "urn:ietf:params:oauth:token-type:access_token" is currently supported. * @param membershipId - Optional membership ID to restrict the exchange to. */ export async function exchangeExternalAuthToken( req: Request, res: Response, clientId: string, subjectToken: string, subjectTokenType: OAuthTokenType, membershipId?: string ): Promise<void> { if (!clientId) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } if (!subjectToken) { sendTokenError(res, 'invalid_request', 'Invalid subject_token'); return; } if (subjectTokenType !== OAuthTokenType.AccessToken) { sendTokenError(res, 'invalid_request', 'Invalid subject_token_type'); return; } const systemRepo = getSystemRepo(); const projectId = await getProjectIdByClientId(clientId, undefined); const client = await systemRepo.readResource<ClientApplication>('ClientApplication', clientId); const idp = client.identityProvider; if (!idp) { sendTokenError(res, 'invalid_request', 'Invalid client'); return; } let userInfo; try { userInfo = await getExternalUserInfo(idp.userInfoUrl, subjectToken, idp); } catch (err: any) { const outcome = normalizeOperationOutcome(err); sendTokenError(res, 'invalid_request', normalizeErrorString(err), getStatus(outcome)); return; } let email: string | undefined = undefined; let externalId: string | undefined = undefined; if (idp.useSubject) { externalId = userInfo.sub as string; } else { email = userInfo.email as string; } const login = await tryLogin({ authMethod: 'exchange', email, externalId, projectId, clientId, scope: req.body.scope || 'openid offline', nonce: req.body.nonce || randomUUID(), remoteAddress: req.ip, userAgent: req.get('User-Agent'), forceUseFirstMembership: true, membershipId, }); await sendTokenResponse(res, login, client); } /** * Tries to extract the client ID and secret from the request. * * Possible methods: * 1. Client assertion (private_key_jwt) * 2. Basic auth header (client_secret_basic) * 3. Form body (client_secret_post) * * See SMART "token_endpoint_auth_methods_supported" * @param req - The HTTP request. * @returns The client ID and secret on success, or an error message on failure. */ async function getClientIdAndSecret(req: Request): Promise<ClientIdAndSecret> { if (req.body.client_assertion_type) { return parseClientAssertion(req.body.client_assertion_type, req.body.client_assertion); } const authHeader = req.headers.authorization; if (authHeader) { return parseAuthorizationHeader(authHeader); } return { clientId: req.body.client_id, clientSecret: req.body.client_secret, }; } /** * Parses a client assertion credential. * * Client assertion works like this: * 1. Client creates a self signed JWT with required fields. * 2. Client must have a configured JWK Set URL. * 3. Server first parses the JWT to get the client ID. * 4. Server looks up the client by ID. * 5. Server verifies the JWT signature using the JWK Set URL. * * References: * 1. https://www.rfc-editor.org/rfc/rfc7523 * 2. https://www.hl7.org/fhir/smart-app-launch/example-backend-services.html#step-2-discovery * 3. https://docs.oracle.com/en/cloud/get-started/subscriptions-cloud/csimg/obtaining-access-token-using-self-signed-client-assertion.html * 4. https://darutk.medium.com/oauth-2-0-client-authentication-4b5f929305d4 * @param clientAssertionType - The client assertion type. * @param clientAssertion - The client assertion JWT. * @returns The parsed client ID and secret on success, or an error message on failure. */ async function parseClientAssertion( clientAssertionType: OAuthClientAssertionType, clientAssertion: string ): Promise<ClientIdAndSecret> { if (clientAssertionType !== OAuthClientAssertionType.JwtBearer) { return { error: 'Unsupported client assertion type' }; } if (!clientAssertion || !isJwt(clientAssertion)) { return { error: 'Invalid client assertion' }; } const { tokenUrl } = getConfig(); const claims = parseJWTPayload(clientAssertion); if (claims.aud !== tokenUrl) { return { error: 'Invalid client assertion audience' }; } if (claims.iss !== claims.sub) { return { error: 'Invalid client assertion issuer' }; } const systemRepo = getSystemRepo(); const clientId = claims.iss as string; let client: ClientApplication; try { client = await systemRepo.readResource<ClientApplication>('ClientApplication', clientId); } catch (_err) { return { error: 'Client not found' }; } if (!client.jwksUri) { return { error: 'Client must have a JWK Set URL' }; } const JWKS = createRemoteJWKSet(new URL(client.jwksUri)); const verifyOptions: JWTVerifyOptions = { issuer: clientId, algorithms: [ OAuthSigningAlgorithm.RS256, OAuthSigningAlgorithm.RS384, OAuthSigningAlgorithm.RS512, OAuthSigningAlgorithm.ES256, OAuthSigningAlgorithm.ES384, OAuthSigningAlgorithm.ES512, ], audience: tokenUrl, }; try { await jwtVerify(clientAssertion, JWKS, verifyOptions); } catch (error: any) { // There are some edge cases where there are multiple matching JWKS // and we need to iterate throught the JWKSMultipleMatchingKeys error // and return the first verified match if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') { return verifyMultipleMatchingException(error, clientId, clientAssertion, verifyOptions, client); } return { error: 'Invalid client assertion signature' }; } // Successfully validated the client assertion return { clientId, clientSecret: client.secret }; } /** * Tries to parse the client ID and secret from the Authorization header. * @param authHeader - The Authorizaiton header string. * @returns Client ID and secret on success, or an error message on failure. */ async function parseAuthorizationHeader(authHeader: string): Promise<ClientIdAndSecret> { if (!authHeader.startsWith('Basic ')) { return { error: 'Invalid authorization header' }; } const base64Credentials = authHeader.split(' ')[1]; const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); const [clientId, clientSecret] = credentials.split(':'); return { clientId, clientSecret }; } async function validateClientIdAndSecret( res: Response, client: ClientApplication | undefined, clientSecret: string ): Promise<boolean> { if (!client?.secret) { sendTokenError(res, 'invalid_request', 'Invalid client'); return false; } let failed = false; // Use a timing-safe-equal here so that we don't expose timing information which could be // used to infer the secret value if (!timingSafeEqualStr(client.secret, clientSecret)) { failed = true; } // Always perform a second comparison, in order to not leak timing information about the presence or absence of // a retiring secret const secondarySecret = client.retiringSecret ?? client.secret; if (timingSafeEqualStr(secondarySecret, clientSecret)) { failed = false; } if (failed) { sendTokenError(res, 'invalid_request', 'Invalid secret'); return false; } else { return true; } } /** * Sends a successful token response. * @param res - The HTTP response. * @param login - The user login. * @param client - The client application. Optional. */ async function sendTokenResponse(res: Response, login: WithId<Login>, client?: ClientApplication): Promise<void> { const config = getConfig(); const systemRepo = getSystemRepo(); const user = await systemRepo.readReference<User>(login.user as Reference<User>); const membership = await systemRepo.readReference<ProjectMembership>( login.membership as Reference<ProjectMembership> ); const tokens = await getAuthTokens(user, login, membership.profile as Reference<ProfileResource>, { accessLifetime: client?.accessTokenLifetime, refreshLifetime: client?.refreshTokenLifetime, }); let patient = undefined; let encounter = undefined; if (login.launch) { const launch = await systemRepo.readReference(login.launch); patient = resolveId(launch.patient); encounter = resolveId(launch.encounter); } if (membership.profile?.reference?.startsWith('Patient/')) { patient = membership.profile.reference.replace('Patient/', ''); } const fhircastProps = {} as FhircastProps; if (login.scope?.includes('fhircast/')) { const userId = resolveId(login.user) as string; let topic: string; try { topic = await getTopicForUser(userId); } catch (err: unknown) { sendTokenError(res, normalizeErrorString(err)); return; } fhircastProps['hub.url'] = `${config.baseUrl}fhircast/STU3`; // TODO: Figure out how to handle the split between STU2 and STU3... fhircastProps['hub.topic'] = topic; } const { exp, iat } = parseJWTPayload(tokens.accessToken); res.status(200).json({ token_type: 'Bearer', expires_in: (exp ?? 0) - (iat ?? 0), scope: login.scope, id_token: tokens.idToken, access_token: tokens.accessToken, refresh_token: tokens.refreshToken, project: membership.project, profile: membership.profile, patient, encounter, smart_style_url: config.baseUrl + 'fhir/R4/.well-known/smart-styles.json', need_patient_banner: !!patient, ...fhircastProps, // Spreads no props when FHIRcast scopes not present }); } /** * Sends an OAuth2 response. * @param res - The HTTP response. * @param error - The error code. See: https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.7 * @param description - The error description. See: https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.8 * @param status - The HTTP status code. * @returns Reference to the HTTP response. */ function sendTokenError(res: Response, error: string, description?: string, status = 400): Response { return res.status(status).json({ error, error_description: description, }); } /** * Verifies the code challenge and verifier. * @param challenge - The code_challenge from the authorization. * @param method - The code_challenge_method from the authorization. * @param verifier - The code_verifier from the token request. * @returns True if the verifier succeeds; false otherwise. */ function verifyCode(challenge: string, method: string, verifier: string): boolean { if (method === 'plain' && challenge === verifier) { return true; } if (method === 'S256' && challenge === hashCode(verifier)) { return true; } return false; }

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