Skip to main content
Glama
external.ts7.63 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { badRequest, ContentType, encodeBase64, OAuthGrantType, OAuthTokenAuthMethod, OperationOutcomeError, parseJWTPayload, } from '@medplum/core'; import type { ClientApplication, IdentityProvider } from '@medplum/fhirtypes'; import type { Request, Response } from 'express'; import fetch from 'node-fetch'; import { randomUUID } from 'node:crypto'; import { getConfig } from '../config/loader'; import { sendOutcome } from '../fhir/outcomes'; import { getLogger, globalLogger } from '../logger'; import { getClientRedirectUri } from '../oauth/clients'; import type { CodeChallengeMethod } from '../oauth/utils'; import { getClientApplication, tryLogin } from '../oauth/utils'; import { getDomainConfiguration } from './method'; /* * External authentication callback * Based on: https://github.com/okta/okta-auth-js/blob/master/samples/generated/express-web-with-oidc/server.js */ export interface ExternalAuthState { domain?: string; projectId?: string; clientId?: string; scope?: string; nonce?: string; launch?: string; codeChallenge?: string; codeChallengeMethod?: CodeChallengeMethod; redirectUri?: string; } export const externalCallbackHandler = async (req: Request, res: Response): Promise<void> => { const code = req.query.code as string; if (!code) { sendOutcome(res, badRequest('Missing code')); return; } const state = req.query.state as string; if (!state) { sendOutcome(res, badRequest('Missing state')); return; } let body: ExternalAuthState; try { body = JSON.parse(state); } catch (_err) { sendOutcome(res, badRequest('Invalid state')); return; } const { idp, client } = await getIdentityProvider(body); if (!idp) { sendOutcome(res, badRequest('Identity provider not found')); return; } const userInfo = await verifyExternalCode(idp, code, body.codeChallenge); let email: string | undefined = undefined; let externalId: string | undefined = undefined; if (idp.useSubject) { externalId = userInfo.sub as string | undefined; if (!externalId) { sendOutcome(res, badRequest('External token does not contain subject')); return; } } else { email = (userInfo.email as string | undefined)?.toLowerCase(); if (!email) { sendOutcome(res, badRequest('External token does not contain email address')); return; } } if (body.domain && !email?.endsWith('@' + body.domain)) { sendOutcome(res, badRequest('Email address does not match domain')); return; } let projectId = body.projectId; if (client) { if (projectId !== undefined && projectId !== client.meta?.project) { sendOutcome(res, badRequest('Invalid project')); return; } projectId = client.meta?.project; } const login = await tryLogin({ authMethod: 'external', email, externalId, projectId: projectId, clientId: body.clientId, scope: body.scope ?? 'openid offline', nonce: body.nonce ?? randomUUID(), launchId: body.launch, codeChallenge: body.codeChallenge, codeChallengeMethod: body.codeChallengeMethod, remoteAddress: req.ip, userAgent: req.get('User-Agent'), }); if (login.membership && body.redirectUri && client) { // Get the redirect URI from the client application // Note that we're currently allowing partial matches for external auth. // This is generally NOT recommended by the OAuth spec. // However, we need to support it here for backwards compatibility with existing clients. const redirectUri = getClientRedirectUri(client, body.redirectUri, true); if (!redirectUri) { sendOutcome(res, badRequest('Invalid redirect URI')); return; } const exactRedirectUri = getClientRedirectUri(client, redirectUri); if (!exactRedirectUri) { getLogger().warn('Redirect URI does not match any of the client application redirect URIs', { clientId: client.id, requestedUri: body.redirectUri, partialMatchUri: redirectUri, }); } const redirectUrl = new URL(redirectUri); redirectUrl.searchParams.set('login', login.id); redirectUrl.searchParams.set('code', login.code as string); res.redirect(redirectUrl.toString()); return; } const signInPage = login.launch ? 'oauth' : 'signin'; const redirectUrl = new URL(signInPage, getConfig().appBaseUrl); redirectUrl.searchParams.set('login', login.id); redirectUrl.searchParams.set('scope', login.scope as string); redirectUrl.searchParams.set('nonce', login.nonce as string); if (login.codeChallenge) { redirectUrl.searchParams.set('code_challenge', login.codeChallenge as string); } if (login.codeChallengeMethod) { redirectUrl.searchParams.set('code_challenge_method', login.codeChallengeMethod as string); } res.redirect(redirectUrl.toString()); }; /** * Tries to find the identity provider configuration. * @param state - The external auth state. * @returns External identity provider definition if found. */ async function getIdentityProvider( state: ExternalAuthState ): Promise<{ idp?: IdentityProvider; client?: ClientApplication }> { let idp: IdentityProvider | undefined; let client: ClientApplication | undefined; if (state.clientId) { client = await getClientApplication(state.clientId); if (client.identityProvider) { return { idp: client.identityProvider, client }; } } if (state.domain) { const domainConfig = await getDomainConfiguration(state.domain); if (domainConfig?.identityProvider) { idp = domainConfig.identityProvider; } } return { idp, client }; } /** * Returns ID token claims for the authorization code. * @param idp - The identity provider configuration. * @param code - The authorization code. * @param codeVerifier - The code verifier. * @returns ID token claims. */ async function verifyExternalCode( idp: IdentityProvider, code: string, codeVerifier: string | undefined ): Promise<Record<string, unknown>> { const headers: HeadersInit = { Accept: ContentType.JSON, 'Content-Type': ContentType.FORM_URL_ENCODED, }; const params = new URLSearchParams(); params.append('grant_type', OAuthGrantType.AuthorizationCode); params.append('redirect_uri', getConfig().baseUrl + 'auth/external'); params.append('code', code); if (idp.usePkce && codeVerifier) { params.append('code_verifier', codeVerifier); } if (idp.tokenAuthMethod === OAuthTokenAuthMethod.ClientSecretPost) { params.append('client_id', idp.clientId as string); params.append('client_secret', idp.clientSecret as string); } else { // Default to client_secret_basic headers.Authorization = `Basic ${encodeBase64(idp.clientId + ':' + idp.clientSecret)}`; } try { const response = await fetch(idp.tokenUrl as string, { method: 'POST', headers, body: params.toString(), }); const responseBody = await response.json(); if (!response.ok) { globalLogger.warn('Bad response from external auth check', { status: response.status, body: responseBody }); throw new OperationOutcomeError(badRequest('Failed to verify code - check your identity provider configuration')); } return parseJWTPayload(responseBody.id_token); } catch (err: any) { globalLogger.warn('Unhandled error in external auth check', err); throw new OperationOutcomeError(badRequest('Failed to verify code - check your identity provider configuration')); } }

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