Skip to main content
Glama
utils.test.ts22.2 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { OperationOutcomeError, WithId } from '@medplum/core'; import { createReference } from '@medplum/core'; import type { ClientApplication, Login, Patient, Project, ProjectMembership, User } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; import { initAppServices, shutdownApp } from '../app'; import { loadTestConfig } from '../config/loader'; import type { Repository } from '../fhir/repo'; import { getSystemRepo } from '../fhir/repo'; import { createTestClient, createTestProject, withTestContext } from '../test.setup'; import { verifyJwt } from './keys'; import { getAuthTokens, getClientApplication, getMembershipsForLogin, normalizeUserInfoUrl, tryLogin, validateLoginRequest, validatePkce, verifyMfaToken, } from './utils'; let client: ClientApplication; describe('OAuth utils', () => { beforeAll(async () => { const config = await loadTestConfig(); await initAppServices(config); client = await createTestClient(); }); afterAll(async () => { await shutdownApp(); }); test('Login with invalid client ID', async () => { try { await tryLogin({ clientId: randomUUID(), authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Not found'); } }); test('Login with missing email', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'password', email: '', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Invalid email'); } }); test('Login with missing password', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: '', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Invalid password'); } }); test('User not found', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'password', email: 'user-not-found@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toStrictEqual('User not found'); } }); test('Blank authentication method', async () => { try { await tryLogin({ clientId: client.id, authMethod: '' as unknown as 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('Invalid authentication method'); } }); test('Invalid authentication method', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'xyz' as unknown as 'password', email: 'admin@example.com', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('Invalid authentication method'); } }); test('Invalid google credentials', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'google', email: 'admin@example.com', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('Invalid google credentials'); } }); test('Invalid scope', async () => { try { await tryLogin({ clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: '', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('Invalid scope'); } }); test('Login with externalId and missing projectId', async () => { try { await tryLogin({ authMethod: 'external', externalId: 'external', scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('Project ID is required for external ID'); } }); test('Login with externalId not found', async () => { try { await tryLogin({ authMethod: 'external', projectId: randomUUID(), externalId: randomUUID(), scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.severity).toStrictEqual('error'); expect(outcome.issue?.[0]?.details?.text).toBe('User not found'); } }); test('Login successfully', async () => { const login = await withTestContext(() => tryLogin({ clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }) ); expect(login).toBeDefined(); }); test('External auth without email or externalId', () => { try { validateLoginRequest({ authMethod: 'external', scope: 'openid', nonce: 'nonce', projectId: randomUUID(), }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Missing email or externalId'); } }); test('External auth without projectId', () => { try { validateLoginRequest({ authMethod: 'external', externalId: randomUUID(), scope: 'openid', nonce: 'nonce', }); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Project ID is required for external ID'); } }); test('Missing codeChallengeMethod', () => { // If user submits codeChallenge, then codeChallengeMethod is required try { validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', codeChallenge: 'xyz', }, undefined ); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.expression?.[0]).toStrictEqual('code_challenge_method'); } }); test('Missing codeChallenge', () => { // If user submits codeChallengeMethod, then codeChallenge is required try { validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', codeChallengeMethod: 'plain', }, client ); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.expression?.[0]).toStrictEqual('code_challenge'); } }); test('Invalid codeChallengeMethod', () => { try { validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', codeChallenge: 'xyz', // @ts-expect-error Invalid `codeChallengeMethod` value, must be `S256` or `plain` codeChallengeMethod: 'xyz', }, client ); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.expression?.[0]).toStrictEqual('code_challenge_method'); } }); test('Plain text code challenge method', () => { expect(() => validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', codeChallenge: 'xyz', codeChallengeMethod: 'plain', }, client ) ).not.toThrow(); }); test('S256 code challenge method', () => { expect(() => validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', codeChallenge: 'xyz', codeChallengeMethod: 'S256', }, client ) ).not.toThrow(); }); test('Client application PKCE optional', () => { const client: ClientApplication = { resourceType: 'ClientApplication', id: randomUUID(), pkceOptional: true, }; expect(() => validatePkce( { clientId: client.id, authMethod: 'password', email: 'admin@example.com', password: 'medplum_admin', scope: 'openid', nonce: 'nonce', }, client ) ).not.toThrow(); }); test('verifyMfaToken login revoked', async () => { try { await verifyMfaToken({ resourceType: 'Login', revoked: true } as Login, 'token'); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Login revoked'); } }); test('verifyMfaToken login granted', async () => { try { await verifyMfaToken({ resourceType: 'Login', granted: true } as Login, 'token'); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Login granted'); } }); test('verifyMfaToken login already verified', async () => { try { await verifyMfaToken({ resourceType: 'Login', mfaVerified: true } as Login, 'token'); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Login already verified'); } }); test('getMembershipsForLogin missing user reference', async () => { try { await getMembershipsForLogin({ resourceType: 'Login', user: {} } as Login); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('User reference is missing'); } }); test('getAuthTokens Login missing profile', async () => { try { await getAuthTokens( { resourceType: 'User', id: '123', firstName: 'John', lastName: 'Doe' }, { resourceType: 'Login', id: '456', user: { reference: 'User/123' } } as WithId<Login>, { reference: 'Patient/123', } ); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome.issue?.[0]?.details?.text).toStrictEqual('Login missing profile'); } }); describe('getAuthTokens with email scope', () => { let project: WithId<Project>; let repo: Repository; beforeAll(async () => { await withTestContext(async () => { const result = await createTestProject({ withRepo: true }); project = result.project; repo = result.repo as Repository; }); }); test('Access token includes email when email scope is requested for User', async () => { await withTestContext(async () => { const systemRepo = getSystemRepo(); // Create a User with email const userEmail = `test-${randomUUID()}@example.com`; const user = await systemRepo.createResource<User>({ resourceType: 'User', email: userEmail, firstName: 'Test', lastName: 'User', }); // Create a Patient profile const patient = await repo.createResource<Patient>({ resourceType: 'Patient', }); // Create a ProjectMembership for the user const membership = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', user: createReference(user), profile: createReference(patient), project: createReference(project), }); // Create a Login with email scope const login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'password', user: createReference(user), membership: createReference(membership), scope: 'openid profile email', granted: true, authTime: new Date().toISOString(), nonce: randomUUID(), } as Login); const tokens = await getAuthTokens(user, login, createReference(patient)); // Verify access token includes email const accessTokenClaims = (await verifyJwt(tokens.accessToken)).payload; expect(accessTokenClaims.email).toBe(userEmail); expect(accessTokenClaims.login_id).toBe(login.id); expect(accessTokenClaims.scope).toBe('openid profile email'); // Verify ID token also includes email const idTokenClaims = (await verifyJwt(tokens.idToken)).payload; expect(idTokenClaims.email).toBe(userEmail); }); }); test('Access token does not include email when email scope is not requested', async () => { await withTestContext(async () => { const systemRepo = getSystemRepo(); // Create a User with email const userEmail = `test-${randomUUID()}@example.com`; const user = await systemRepo.createResource<User>({ resourceType: 'User', email: userEmail, firstName: 'Test', lastName: 'User', }); // Create a Patient profile const patient = await repo.createResource<Patient>({ resourceType: 'Patient', }); // Create a ProjectMembership for the user const membership = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', user: createReference(user), profile: createReference(patient), project: createReference(project), }); // Create a Login without email scope const login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'password', user: createReference(user), membership: createReference(membership), scope: 'openid profile', granted: true, authTime: new Date().toISOString(), nonce: randomUUID(), } as Login); const tokens = await getAuthTokens(user, login, createReference(patient)); // Verify access token does not include email const accessTokenClaims = (await verifyJwt(tokens.accessToken)).payload; expect(accessTokenClaims.email).toBeUndefined(); expect(accessTokenClaims.login_id).toBe(login.id); expect(accessTokenClaims.scope).toBe('openid profile'); // Verify ID token also does not include email const idTokenClaims = (await verifyJwt(tokens.idToken)).payload; expect(idTokenClaims.email).toBeUndefined(); }); }); test('Access token does not include email for ClientApplication even with email scope', async () => { await withTestContext(async () => { const systemRepo = getSystemRepo(); // Create a ClientApplication const client = await repo.createResource<ClientApplication>({ resourceType: 'ClientApplication', name: 'Test Client', secret: randomUUID(), }); // Create a Patient profile const patient = await repo.createResource<Patient>({ resourceType: 'Patient', }); // Create a ProjectMembership for the client const membership = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', user: createReference(client), profile: createReference(patient), project: createReference(project), }); // Create a Login with email scope const login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'client', user: createReference(client), client: createReference(client), membership: createReference(membership), scope: 'openid profile email', granted: true, authTime: new Date().toISOString(), nonce: randomUUID(), } as Login); const tokens = await getAuthTokens(client, login, createReference(patient)); // Verify access token does not include email (ClientApplication is not a User) const accessTokenClaims = (await verifyJwt(tokens.accessToken)).payload; expect(accessTokenClaims.email).toBeUndefined(); expect(accessTokenClaims.login_id).toBe(login.id); expect(accessTokenClaims.scope).toBe('openid profile email'); // Verify ID token also does not include email const idTokenClaims = (await verifyJwt(tokens.idToken)).payload; expect(idTokenClaims.email).toBeUndefined(); }); }); test('Access token does not include email when user has no email address', async () => { await withTestContext(async () => { const systemRepo = getSystemRepo(); // Create a User without email const user = await systemRepo.createResource<User>({ resourceType: 'User', firstName: 'Test', lastName: 'User', // email is undefined }); // Create a Patient profile const patient = await repo.createResource<Patient>({ resourceType: 'Patient', }); // Create a ProjectMembership for the user const membership = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', user: createReference(user), profile: createReference(patient), project: createReference(project), }); // Create a Login with email scope const login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'password', user: createReference(user), membership: createReference(membership), scope: 'openid profile email', granted: true, authTime: new Date().toISOString(), nonce: randomUUID(), } as Login); const tokens = await getAuthTokens(user, login, createReference(patient)); // Verify access token does not include email (user has no email) const accessTokenClaims = (await verifyJwt(tokens.accessToken)).payload; expect(accessTokenClaims.email).toBeUndefined(); expect(accessTokenClaims.login_id).toBe(login.id); expect(accessTokenClaims.scope).toBe('openid profile email'); // Verify ID token also does not include email const idTokenClaims = (await verifyJwt(tokens.idToken)).payload; expect(idTokenClaims.email).toBeUndefined(); }); }); }); test('CLI client', async () => { const client = await getClientApplication('medplum-cli'); expect(client).toBeDefined(); expect(client.id).toStrictEqual('medplum-cli'); }); describe('normalizeUserInfoUrl', () => { test.each([ ['http://example.com/oauth2/userinfo', false], [' http://example.com/oauth2/userinfo ', false], ['https://example.com/oauth2/userinfo', false], [' https://example.com/oauth2/userinfo ', false], ['file://example.com/oauth2/userinfo', true], [' file://example.com/oauth2/userinfo ', true], ])('with URL [%s]', (userInfoUrl, expectError) => { try { normalizeUserInfoUrl(userInfoUrl); if (expectError) { fail('Expected error'); } } catch (err) { if (!expectError) { throw err; } } }); }); }); function fail(message: string): never { throw new Error(message); }

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