Skip to main content
Glama
authorize.ts10.2 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { getDateProperty, Operator } from '@medplum/core'; import type { ClientApplication, Login } from '@medplum/fhirtypes'; import type { Request, Response } from 'express'; import { URL } from 'node:url'; import { getConfig } from '../config/loader'; import { getSystemRepo } from '../fhir/repo'; import { getLogger } from '../logger'; import { getClientRedirectUri } from './clients'; import type { MedplumIdTokenClaims } from './keys'; import { generateSecret, verifyJwt } from './keys'; import { getClientApplication } from './utils'; /* * Handles the OAuth/OpenID Authorization Endpoint. * See: https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint */ /** * HTTP GET handler for /oauth2/authorize endpoint. * @param req - The request object * @param res - The response object */ export const authorizeGetHandler = async (req: Request, res: Response): Promise<void> => { const validateResult = await validateAuthorizeRequest(req, res, req.query); if (!validateResult) { return; } sendSuccessRedirect(req, res, req.query); }; /** * HTTP POST handler for /oauth2/authorize endpoint. * @param req - The request object * @param res - The response object */ export const authorizePostHandler = async (req: Request, res: Response): Promise<void> => { const validateResult = await validateAuthorizeRequest(req, res, req.body); if (!validateResult) { return; } sendSuccessRedirect(req, res, req.body); }; /** * Validates the OAuth/OpenID Authorization Endpoint configuration. * This is used for both GET and POST requests. * We currently only support query string parameters. * See: https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint * @param req - The HTTP request. * @param res - The HTTP response. * @param params - The params (query string params for GET, form body params for POST). * @returns True on success; false on error. */ async function validateAuthorizeRequest(req: Request, res: Response, params: Record<string, any>): Promise<boolean> { // First validate the client and the redirect URI. // If these are invalid, then show an error page. let client = undefined; try { client = await getClientApplication(params.client_id as string); } catch (_err) { res.status(400).send('Client not found'); return false; } if (!params.redirect_uri) { res.status(400).send('Missing redirect URI'); return false; } if (!URL.canParse(params.redirect_uri)) { res.status(400).send('Invalid redirect URI'); return false; } const redirectUri = getClientRedirectUri(client, params.redirect_uri); if (!redirectUri) { res.status(400).send('Invalid redirect URI'); return false; } const state = params.state ?? ''; // Then, validate all other parameters. // If these are invalid, redirect back to the redirect URI. params.scope ??= client.defaultScope?.join(' '); if (!params.scope) { sendErrorRedirect(res, redirectUri, 'invalid_request', 'Missing scope', state); return false; } if (params.response_type !== 'code') { sendErrorRedirect(res, redirectUri, 'unsupported_response_type', 'Invalid response type', state); return false; } if (params.request) { sendErrorRedirect(res, redirectUri, 'request_not_supported', 'Unsupported request parameter', state); return false; } if (!isValidAudience(params.aud)) { sendErrorRedirect(res, redirectUri, 'invalid_request', 'Invalid audience', state); return false; } if (params.code_challenge && !params.code_challenge_method) { sendErrorRedirect(res, redirectUri, 'invalid_request', 'Missing code challenge method', state); return false; } if (params.launch && !(await isValidLaunch(params.launch))) { sendErrorRedirect(res, redirectUri, 'invalid_request', 'Invalid launch', state); return false; } const existingLogin = await getExistingLogin(req, client); const prompt = params.prompt as string | undefined; if (prompt === 'none' && !existingLogin) { sendErrorRedirect(res, redirectUri, 'login_required', 'Login required', state); return false; } if (prompt !== 'login' && existingLogin) { const systemRepo = getSystemRepo(); const updatedLogin = await systemRepo.updateResource<Login>({ ...existingLogin, nonce: params.nonce as string, codeChallenge: params.code_challenge ?? existingLogin.codeChallenge, codeChallengeMethod: params.code_challenge_method ?? existingLogin.codeChallengeMethod, code: generateSecret(16), launch: params.launch ? { reference: `SmartAppLaunch/${params.launch}` } : existingLogin.launch, granted: false, }); if (prompt === 'none') { // Redirect straight to application without allowing scope changes const redirectUrl = new URL(params.redirect_uri as string); redirectUrl.searchParams.append('code', updatedLogin.code as string); redirectUrl.searchParams.append('state', state); res.redirect(redirectUrl.toString()); } else { // Redirect to scope selection page to allow consent to updated scopes params.login = updatedLogin.id; if (!params.scope) { params.scope = updatedLogin.scope; } sendSuccessRedirect(req, res, params); } return false; } return true; } /** * Returns true if the audience is valid. * @param aud - The user provided audience. * @returns True if the audience is valid; false otherwise. */ function isValidAudience(aud: string | undefined): boolean { if (!aud) { // Allow missing aud parameter. // Technically, aud is required: https://www.hl7.org/fhir/smart-app-launch/app-launch.html#obtain-authorization-code // However, some FHIR validation tools do not include it, so we silently ignore missing values. return true; } try { const audUrl = new URL(aud); const serverUrl = new URL(getConfig().baseUrl); return audUrl.protocol === serverUrl.protocol && audUrl.host === serverUrl.host; } catch (_err) { return false; } } /** * Returns true if the launch parameter is valid. * @param launch - The launch parameter (which is a SmartAppLaunch ID). * @returns True if the launch is valid; false otherwise. */ async function isValidLaunch(launch: string): Promise<boolean> { const systemRepo = getSystemRepo(); try { await systemRepo.readResource('SmartAppLaunch', launch); return true; } catch (_err) { return false; } } /** * Tries to get an existing login for the current request. * @param req - The HTTP request. * @param client - The current client application. * @returns Existing login if found; undefined otherwise. */ async function getExistingLogin(req: Request, client: ClientApplication): Promise<Login | undefined> { const login = (await getExistingLoginFromIdTokenHint(req)) || (await getExistingLoginFromCookie(req, client)); if (!login) { return undefined; } const authTime = getDateProperty(login.authTime) as Date; const age = (Date.now() - authTime.getTime()) / 1000; const maxAge = req.query.max_age ? Number.parseInt(req.query.max_age as string, 10) : 3600; if (age > maxAge) { return undefined; } return login; } /** * Tries to get an existing login based on the "id_token_hint" query string parameter. * @param req - The HTTP request. * @returns Existing login if found; undefined otherwise. */ async function getExistingLoginFromIdTokenHint(req: Request): Promise<Login | undefined> { const idTokenHint = req.query.id_token_hint as string | undefined; if (!idTokenHint) { return undefined; } let verifyResult; try { verifyResult = await verifyJwt(idTokenHint); } catch (err: any) { getLogger().debug('Error verifying id_token_hint', err); return undefined; } const claims = verifyResult.payload as MedplumIdTokenClaims; const existingLoginId = claims.login_id as string | undefined; if (!existingLoginId) { return undefined; } const systemRepo = getSystemRepo(); return systemRepo.readResource<Login>('Login', existingLoginId); } /** * Tries to get an existing login based on the HTTP cookies. * @param req - The HTTP request. * @param client - The current client application. * @returns Existing login if found; undefined otherwise. */ async function getExistingLoginFromCookie(req: Request, client: ClientApplication): Promise<Login | undefined> { const cookieName = 'medplum-' + client.id; const cookieValue = req.cookies[cookieName]; if (!cookieValue) { return undefined; } const systemRepo = getSystemRepo(); const bundle = await systemRepo.search<Login>({ resourceType: 'Login', filters: [ { code: 'cookie', operator: Operator.EQUALS, value: cookieValue, }, ], }); const login = bundle.entry?.[0]?.resource; return login?.granted && !login.revoked ? login : undefined; } /** * Sends a redirect back to the client application with error codes and state. * @param res - The response. * @param redirectUri - The client redirect URI. This URI may already have query string parameters. * @param error - The OAuth/OpenID error code. * @param errorDescription - The error description. * @param state - The client state. */ function sendErrorRedirect( res: Response, redirectUri: string, error: string, errorDescription: string, state: string ): void { const url = new URL(redirectUri); url.searchParams.append('error', error); url.searchParams.append('error_description', errorDescription); url.searchParams.append('state', state); res.redirect(url.toString()); } /** * Sends a successful redirect. * @param req - The HTTP request. * @param res - The HTTP response. * @param params - The redirect parameters. */ function sendSuccessRedirect(req: Request, res: Response, params: Record<string, any>): void { const redirectUrl = new URL(getConfig().appBaseUrl + 'oauth'); for (const [name, value] of Object.entries(params)) { redirectUrl.searchParams.set(name, value); } res.redirect(redirectUrl.toString()); }

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