Skip to main content
Glama
token.test.ts69.9 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { WithId } from '@medplum/core'; import { ContentType, createReference, encodeBase64, encodeBase64Url, OAuthClientAssertionType, OAuthGrantType, OAuthTokenType, parseJWTPayload, parseSearchRequest, } from '@medplum/core'; import type { AccessPolicy, ClientApplication, Login, Project, SmartAppLaunch } from '@medplum/fhirtypes'; import express from 'express'; import { decodeJwt, generateKeyPair, jwtVerify, SignJWT } from 'jose'; import fetch from 'node-fetch'; import { randomUUID } from 'node:crypto'; import request from 'supertest'; import { createClient } from '../admin/client'; import { inviteUser } from '../admin/invite'; import { initApp, shutdownApp } from '../app'; import { setPassword } from '../auth/setpassword'; import { loadTestConfig } from '../config/loader'; import type { MedplumServerConfig } from '../config/types'; import { getSystemRepo } from '../fhir/repo'; import { createTestProject, withTestContext } from '../test.setup'; import { generateSecret, verifyJwt } from './keys'; import { hashCode } from './utils'; jest.mock('jose', () => { const core = jest.requireActual('@medplum/core'); const original = jest.requireActual('jose'); let count = 0; return { ...original, jwtVerify: jest.fn((credential: string) => { const payload = core.parseJWTPayload(credential); if (payload.invalid) { throw new Error('Verification failed'); } if (payload.multipleMatching) { count = payload.successVerified ? count + 1 : 0; let error: MockJoseMultipleMatchingError; if (count <= 1) { error = new MockJoseMultipleMatchingError( 'multiple matching keys found in the JSON Web Key Set', 'ERR_JWKS_MULTIPLE_MATCHING_KEYS' ); } else if (count === 2) { error = new MockJoseMultipleMatchingError('Verification fail', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED'); } else { return { payload }; } throw error; } return { payload }; }), }; }); jest.mock('node-fetch'); describe('OAuth2 Token', () => { const app = express(); const systemRepo = getSystemRepo(); const domain = randomUUID() + '.example.com'; const email = `text@${domain}`; const password = randomUUID(); const redirectUri = `https://${domain}/auth/callback`; let config: MedplumServerConfig; let project: WithId<Project>; let client: WithId<ClientApplication>; let pkceOptionalClient: ClientApplication; let externalAuthClient: ClientApplication; let invalidAuthClient: ClientApplication; beforeAll(async () => { config = await loadTestConfig(); await initApp(app, config); // Create a test project ({ project, client } = await createTestProject({ withClient: true })); // Add secondary secret for testing client.retiringSecret = generateSecret(32); client = await systemRepo.updateResource(client); // Create a 2nd client with PKCE optional pkceOptionalClient = await systemRepo.createResource<ClientApplication>({ resourceType: 'ClientApplication', secret: generateSecret(32), retiringSecret: generateSecret(32), pkceOptional: true, }); // Create access policy const accessPolicy = await systemRepo.createResource<AccessPolicy>({ resourceType: 'AccessPolicy', resource: [{ resourceType: '*' }], ipAccessRule: [ { name: 'Block test', value: '6.6.6.6', action: 'block' }, { name: 'Allow by default', value: '*', action: 'allow' }, ], }); // Create a test user const { user, membership } = await inviteUser({ project, resourceType: 'Practitioner', firstName: 'Test', lastName: 'User', email, }); // Set the access policy await systemRepo.updateResource({ ...membership, accessPolicy: createReference(accessPolicy), }); // Set the test user password await setPassword(user, password); // Create a new client application with external auth externalAuthClient = await createClient(systemRepo, { project, name: 'External Auth Client', redirectUri, identityProvider: { authorizeUrl: 'https://example.com/oauth2/authorize', tokenUrl: 'https://example.com/oauth2/token', userInfoUrl: 'https://example.com/oauth2/userinfo', clientId: '123', clientSecret: '456', }, }); // Create an invalid external auth client with invalid URLs invalidAuthClient = await createClient(systemRepo, { project, name: 'Invalid Auth Client', redirectUri, identityProvider: { authorizeUrl: 'file://example.com/oauth2/authorize', tokenUrl: 'file://example.com/oauth2/token', userInfoUrl: 'file://example.com/oauth2/userinfo', clientId: '123', clientSecret: '456', }, }); }); afterEach(() => { jest.clearAllMocks(); }); afterAll(async () => { await shutdownApp(); }); test('Token with wrong Content-Type', async () => { const res = await request(app).post('/oauth2/token').type('json').send({ foo: 'bar', }); expect(res.status).toBe(400); expect(res.text).toBe('Unsupported content type'); }); test('Token with missing grant type', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: '', code: 'fake-code', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Missing grant_type'); }); test('Token with unsupported grant type', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'xyz', code: 'fake-code', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Unsupported grant_type'); }); test('Token for client credentials success', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); }); test('Token for client credentials with missing client_id', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: '', client_secret: 'big-long-string', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Missing client_id'); }); test('Token for client credentials with missing client_secret', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: '', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Missing client_secret'); }); test('Token for client credentials with wrong client_id', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: randomUUID(), client_secret: 'big-long-string', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token for client credentials with wrong client_secret', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: 'wrong-client-id', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid secret'); }); test('Token for client credentials with secondary client_secret', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.retiringSecret, }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); }); test('Token for client credentials authentication header success', async () => { const res = await request(app) .post('/oauth2/token') .type('form') .set('Authorization', 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64')) .send({ grant_type: 'client_credentials', }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); }); test('Token for client credentials wrong authentication header', async () => { const res = await request(app).post('/oauth2/token').type('form').set('Authorization', 'Bearer xyz').send({ grant_type: 'client_credentials', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid authorization header'); }); test('Token for client empty secret', async () => { // Create a client without an secret const badClient = await withTestContext(() => systemRepo.createResource<ClientApplication>({ resourceType: 'ClientApplication', name: 'Bad Client', description: 'Bad Client', secret: '', redirectUris: ['https://example.com'], }) ); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: badClient.id, client_secret: 'wrong-client-secret', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token for client without project membership', async () => { const client = await withTestContext(() => systemRepo.createResource<ClientApplication>({ resourceType: 'ClientApplication', name: 'Client without project membership', secret: generateSecret(32), }) ); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token for client in "off" status', async () => { const { client } = await createTestProject({ withClient: true }); await withTestContext(() => systemRepo.updateResource<ClientApplication>({ ...client, status: 'off' })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token for client in "active" status', async () => { const { client } = await createTestProject({ withClient: true }); await withTestContext(() => systemRepo.updateResource<ClientApplication>({ ...client, status: 'active' })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); }); test('Client credentials IP address restriction', async () => { const { client } = await createTestProject({ withClient: true, withAccessToken: true, accessPolicy: { resourceType: 'AccessPolicy', resource: [{ resourceType: '*' }], ipAccessRule: [ { name: 'Block test', value: '6.6.6.6', action: 'block' }, { name: 'Allow by default', value: '*', action: 'allow' }, ], }, }); // Login with client credentials from 6.6.6.6 // Should fail because of IP address block const res1 = await request(app).post('/oauth2/token').set('X-Forwarded-For', '6.6.6.6').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res1.status).toBe(400); expect(res1.body.error).toBe('invalid_request'); expect(res1.body.error_description).toBe('IP address not allowed'); // Login with client credentials from 5.5.5.5 // Should succeed const res2 = await request(app).post('/oauth2/token').set('X-Forwarded-For', '5.5.5.5').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res2.status).toBe(200); expect(res2.body.error).toBeUndefined(); expect(res2.body.access_token).toBeDefined(); }); test('Token for authorization_code with missing code', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: '', code_verifier: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Missing code'); }); test('Token for authorization_code with bad code', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: 'xyzxyz', code_verifier: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid code'); }); test('Token for authorization_code with invalid client ID', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', client_id: 'INVALID', code: '', code_verifier: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Missing code'); }); test('Token for authorization_code with invalid authorization header', async () => { const res = await request(app).post('/oauth2/token').set('Authorization', 'Bearer xyz').type('form').send({ grant_type: 'authorization_code', code: '', code_verifier: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid authorization header'); }); test('Authorization code missing verification', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, }); expect(res2.status).toBe(400); expect(res2.body.error).toBe('invalid_request'); expect(res2.body.error_description).toBe('Missing verification context'); }); test('Authorization code missing code_verifier', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, }); expect(res2.status).toBe(400); expect(res2.body.error).toBe('invalid_request'); expect(res2.body.error_description).toBe('Missing code verifier'); }); test('Authorization code token with code verifier success', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid profile email', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid profile email'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeUndefined(); const idToken = parseJWTPayload(res2.body.id_token); expect(idToken.email).toBe(email); }); test('Authorization code token with incorrect code verifier and client secret', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid profile email', }); expect(res.status).toBe(200); const res2 = await request(app) .post('/oauth2/token') .set('Authorization', 'Basic ' + encodeBase64(client.id + ':' + client.secret)) .type('form') .send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'incorrect', }); expect(res2.status).toBe(400); expect(res2.body.access_token).toBeUndefined(); }); test('Authorization code token with code challenge and PKCE optional', async () => { const res = await request(app).post('/auth/login').type('json').send({ clientId: pkceOptionalClient.id, email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeUndefined(); }); test('Authorization code token with wrong client secret', async () => { const res = await request(app).post('/auth/login').type('json').send({ clientId: pkceOptionalClient.id, email, password, }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, client_id: pkceOptionalClient.id, client_secret: 'wrong', }); expect(res2.status).toBe(400); expect(res2.body.error).toBe('invalid_request'); expect(res2.body.error_description).toBe('Invalid secret'); }); test('Authorization code token with secondary client secret', async () => { const res = await request(app).post('/auth/login').type('json').send({ clientId: pkceOptionalClient.id, email, password, }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, client_id: pkceOptionalClient.id, client_secret: pkceOptionalClient.retiringSecret, }); expect(res2.status).toBe(200); expect(res2.body.error).toBeUndefined(); expect(res2.body.access_token).toBeDefined(); }); test('Authorization code token with client secret success', async () => { const res = await request(app).post('/auth/login').type('json').send({ clientId: pkceOptionalClient.id, email, password, }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, client_id: pkceOptionalClient.id, client_secret: pkceOptionalClient.secret, }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeUndefined(); }); test('Authorization code revoked', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); // Find the login const loginBundle = await systemRepo.search<Login>(parseSearchRequest('Login?code=' + res.body.code)); expect(loginBundle.entry).toHaveLength(1); // Revoke the login const login = loginBundle.entry?.[0]?.resource as Login; await withTestContext(() => systemRepo.updateResource({ ...login, revoked: true, }) ); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(400); expect(res2.body.error).toBe('invalid_grant'); expect(res2.body.error_description).toBe('Token revoked'); }); test('Authorization code token success with refresh using legacy remember flag', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', remember: true, }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); }); test('Authorization code token success with refresh using scope=offline', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); }); test('Authorization code token success with refresh using scope=offline_access', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline_access', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline_access'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); }); test('Authorization code token success with client ID', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', client_id: client.id, code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); }); test('Authorization code token failure with client ID', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', client_id: 'wrong-client-id', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(400); expect(res2.body.error).toBe('invalid_request'); expect(res2.body.error_description).toBe('Invalid client'); }); test('Authorization code token failure already granted', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res3.status).toBe(400); expect(res3.body.error).toBe('invalid_grant'); expect(res3.body.error_description).toBe('Token already granted'); }); test('Refresh token without token', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: '', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid refresh token'); }); test('Refresh token with malformed token', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid refresh token'); }); test('Refresh token success', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(200); expect(res3.body.token_type).toBe('Bearer'); expect(res3.body.scope).toBe('openid offline'); expect(res3.body.expires_in).toBe(3600); expect(res3.body.id_token).toBeDefined(); expect(res3.body.access_token).toBeDefined(); expect(res3.body.refresh_token).toBeDefined(); }); test('Refresh token failed for no refresh secret', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeUndefined(); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(400); expect(res3.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid refresh token', }); }); test('Refresh token failed for no refresh_secret claim', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const refreshToken = res2.body.refresh_token; const decodedToken = decodeJwt(refreshToken); decodedToken['refresh_secret'] = undefined; const invalidRefreshToken = new SignJWT(decodedToken); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: invalidRefreshToken, }); expect(res3.status).toBe(400); expect(res3.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid refresh token', }); }); test('Refresh token failure with S256 code', async () => { const code = randomUUID(); const codeHash = hashCode(code); const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: codeHash, codeChallengeMethod: 'S256', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: codeHash, // sending hash, should be code }); expect(res2.status).toBe(400); }); test('Refresh token success with S256 code', async () => { const code = randomUUID(); const codeHash = hashCode(code); const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: codeHash, codeChallengeMethod: 'S256', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: code, }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(200); expect(res3.body.token_type).toBe('Bearer'); expect(res3.body.scope).toBe('openid offline'); expect(res3.body.expires_in).toBe(3600); expect(res3.body.id_token).toBeDefined(); expect(res3.body.access_token).toBeDefined(); expect(res3.body.refresh_token).toBeDefined(); }); test('Refresh token revoked', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); // Find the login const loginBundle = await systemRepo.search<Login>(parseSearchRequest('Login?code=' + res.body.code)); expect(loginBundle.entry).toHaveLength(1); // Revoke the login const login = loginBundle.entry?.[0]?.resource as Login; await withTestContext(() => systemRepo.updateResource({ ...login, revoked: true, }) ); const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(400); expect(res3.body.error).toBe('invalid_grant'); expect(res3.body.error_description).toBe('Token revoked'); }); test('Refresh token Basic auth success', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app) .post('/oauth2/token') .set('Authorization', 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64')) .type('form') .send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(200); expect(res3.body.token_type).toBe('Bearer'); expect(res3.body.scope).toBe('openid offline'); expect(res3.body.expires_in).toBe(3600); expect(res3.body.id_token).toBeDefined(); expect(res3.body.access_token).toBeDefined(); expect(res3.body.refresh_token).toBeDefined(); }); test('Refresh token Basic auth failure wrong auth type', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app) .post('/oauth2/token') .set('Authorization', 'Bearer ' + Buffer.from(client.id + ':' + client.secret).toString('base64')) .type('form') .send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(400); expect(res3.body.error).toBe('invalid_request'); expect(res3.body.error_description).toBe('Invalid authorization header'); }); test('Refresh token Basic auth failure wrong client ID', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app) .post('/oauth2/token') .set('Authorization', 'Basic ' + Buffer.from('wrong-id:' + client.secret).toString('base64')) .type('form') .send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(400); expect(res3.body.error).toBe('invalid_grant'); expect(res3.body.error_description).toBe('Incorrect client'); }); test('Refresh token Basic auth failure wrong secret', async () => { const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const res3 = await request(app) .post('/oauth2/token') .set('Authorization', 'Basic ' + Buffer.from(client.id + ':').toString('base64')) .type('form') .send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(400); expect(res3.body.error).toBe('invalid_grant'); expect(res3.body.error_description).toBe('Incorrect client secret'); }); test('Refresh token rotation', async () => { // 1) Authorize // 2) Get tokens with grant_type=authorization_code // 3) Get tokens with grant_type=refresh_token // 4) Get tokens again with grant_type=refresh_token // 5) Verify that the first refresh token is invalid // 1) Authorize const res = await request(app).post('/auth/login').type('json').send({ email, password, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); // 2) Get tokens with grant_type=authorization_code const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); // 3) Get tokens with grant_type=refresh_token const res3 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res3.status).toBe(200); expect(res3.body.token_type).toBe('Bearer'); expect(res3.body.scope).toBe('openid offline'); expect(res3.body.expires_in).toBe(3600); expect(res3.body.id_token).toBeDefined(); expect(res3.body.access_token).toBeDefined(); expect(res3.body.refresh_token).toBeDefined(); // 4) Get tokens again with grant_type=refresh_token const res4 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res3.body.refresh_token, }); expect(res4.status).toBe(200); expect(res4.body.token_type).toBe('Bearer'); expect(res4.body.scope).toBe('openid offline'); expect(res4.body.expires_in).toBe(3600); expect(res4.body.id_token).toBeDefined(); expect(res4.body.access_token).toBeDefined(); expect(res4.body.refresh_token).toBeDefined(); // 5) Verify that the first refresh token is invalid const res5 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'refresh_token', refresh_token: res2.body.refresh_token, }); expect(res5.status).toBe(400); expect(res5.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid token' }); }); test('accessTokenLifetime -- Valid duration', async () => { // Create a new client application with external auth const validLifetimeClient = await createClient(systemRepo, { project, name: 'accessTokenLifetime - Valid Client', accessTokenLifetime: '60s', }); expect(validLifetimeClient?.id).toBeDefined(); expect(validLifetimeClient?.secret).toBeDefined(); const res = await request(app).post('/auth/login').type('json').send({ clientId: validLifetimeClient.id, email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', client_id: validLifetimeClient.id, code: res.body.code, code_verifier: 'xyz', scope: 'openid offline', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(60); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const claims = (await verifyJwt(res2.body.access_token)).payload; expect(claims.exp).toStrictEqual((claims.iat as number) + 60); }); test('accessTokenLifetime -- Invalid duration', async () => { // Create a new client application with external auth await expect( createClient(systemRepo, { project, name: 'accessTokenLifetime - Invalid Client', accessTokenLifetime: 'medplum', }) ).rejects.toThrow( /Constraint clapp-1 not met: Token lifetime must be a valid string representing time duration (eg. 2w, 1h)*/ ); await expect( createClient(systemRepo, { project, name: 'accessTokenLifetime - Invalid Client', accessTokenLifetime: '300', }) ).rejects.toThrow( /Constraint clapp-1 not met: Token lifetime must be a valid string representing time duration (eg. 2w, 1h)*/ ); }); test('refreshTokenLifetime -- Valid duration', async () => { // Create a new client application with external auth const validLifetimeClient = await createClient(systemRepo, { project, name: 'refreshTokenLifetime - Valid Client', refreshTokenLifetime: '60s', }); expect(validLifetimeClient?.id).toBeDefined(); expect(validLifetimeClient?.secret).toBeDefined(); const res = await request(app).post('/auth/login').type('json').send({ clientId: validLifetimeClient.id, email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', client_id: validLifetimeClient.id, code: res.body.code, code_verifier: 'xyz', scope: 'openid offline', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeDefined(); const claims = (await verifyJwt(res2.body.refresh_token)).payload; expect(claims.exp).toStrictEqual((claims.iat as number) + 60); }); test('refreshTokenLifetime -- Invalid duration', async () => { // Create a new client application with external auth await expect( createClient(systemRepo, { project, name: 'refreshTokenLifetime - Invalid Client', refreshTokenLifetime: 'medplum', }) ).rejects.toThrow( /Constraint clapp-1 not met: Token lifetime must be a valid string representing time duration (eg. 2w, 1h)*/ ); await expect( createClient(systemRepo, { project, name: 'refreshTokenLifetime - Invalid Client', refreshTokenLifetime: '300', }) ).rejects.toThrow( /Constraint clapp-1 not met: Token lifetime must be a valid string representing time duration (eg. 2w, 1h)*/ ); }); test('Patient in token response', async () => { const patientEmail = `test-patient-${randomUUID()}@example.com`; const patientPassword = 'test-patient-password'; // Invite a test patient const testPatient = await withTestContext(async () => { const patient = await inviteUser({ project, resourceType: 'Patient', firstName: 'Test', lastName: 'Patient', email: patientEmail, }); expect(patient.user).toBeDefined(); expect(patient.profile).toBeDefined(); // Force set the password await setPassword(patient.user, patientPassword); return patient; }); // Authenticate const res = await request(app).post('/auth/login').type('json').send({ email: patientEmail, password: patientPassword, clientId: client.id, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', }); expect(res.status).toBe(200); // Get tokens const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.patient).toStrictEqual(testPatient.profile.id); }); test('Client assertion success', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ 'urn:example:claim': true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); }); test('Client assertion client not found', async () => { const fakeClientId = randomUUID(); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ 'urn:example:claim': true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(fakeClientId) .setSubject(fakeClientId) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Client not found', }); }); test('Client assertion missing jwks URL', async () => { // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ 'urn:example:claim': true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client.id) .setSubject(client.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Client must have a JWK Set URL', }); }); test('Client assertion invalid audience', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ 'urn:example:claim': true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('https://invalid-audience.com') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid client assertion audience', }); }); test('Client assertion invalid issuer', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ 'urn:example:claim': true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer('invalid-issuer') .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid client assertion issuer', }); }); test('Client assertion invalid signature', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ invalid: true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid client assertion signature', }); }); test('Client assertion multiple matching 3rd check success', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ multipleMatching: true, successVerified: true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(200); expect(jwtVerify).toHaveBeenCalledTimes(3); }); test('Client assertion multiple inner error', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ multipleMatching: true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: jwt, }); expect(res.status).toBe(400); expect(jwtVerify).toHaveBeenCalledTimes(2); }); test('Client assertion invalid assertion type', async () => { const client2 = await withTestContext(async () => { // Create a new client const client = await createClient(systemRepo, { project, name: 'Test Client 2' }); // Set the client jwksUri await systemRepo.updateResource<ClientApplication>({ ...client, jwksUri: 'https://example.com/jwks.json' }); return client; }); // Create the JWT const keyPair = await generateKeyPair('ES384'); const jwt = await new SignJWT({ invalid: true }) .setProtectedHeader({ alg: 'ES384' }) .setIssuedAt() .setIssuer(client2.id) .setSubject(client2.id) .setAudience('http://localhost:8103/oauth2/token') .setExpirationTime('2h') .sign(keyPair.privateKey); expect(jwt).toBeDefined(); // Then use the JWT for a client credentials grant const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_assertion_type: 'urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer', client_assertion: jwt, }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Unsupported client assertion type', }); }); test('Client assertion missing JWT', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.ClientCredentials, client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: '', // empty JWT }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid client assertion', }); }); test('Client assertion invalid JWT', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.ClientCredentials, client_assertion_type: OAuthClientAssertionType.JwtBearer, client_assertion: 'foo', // not a valid JWT }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid client assertion', }); }); test('Smart App Launch tokens', async () => { // Create a SmartAppLaunch const launch = await withTestContext(() => systemRepo.createResource<SmartAppLaunch>({ resourceType: 'SmartAppLaunch', patient: { reference: `Patient/${randomUUID()}` }, encounter: { reference: `Encounter/${randomUUID()}` }, }) ); const res = await request(app).post('/auth/login').type('json').send({ clientId: client.id, launch: launch.id, email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, client_id: client.id, client_secret: client.secret, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.patient).toBeDefined(); expect(res2.body.encounter).toBeDefined(); }); test('IP address allow', async () => { const res = await request(app).post('/auth/login').set('X-Forwarded-For', '5.5.5.5').type('json').send({ clientId: client.id, email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, client_id: client.id, client_secret: client.secret, code_verifier: 'xyz', }); expect(res2.status).toBe(200); }); test('IP address block', async () => { const res = await request(app).post('/auth/login').set('X-Forwarded-For', '6.6.6.6').type('json').send({ clientId: client.id, email, password, }); expect(res.status).toBe(400); expect(res.body.issue[0].details.text).toStrictEqual('IP address not allowed'); }); test('Token exchange JSON success', async () => { (fetch as unknown as jest.Mock).mockImplementation(() => ({ status: 200, json: () => ({ email }), headers: { get: () => ContentType.JSON }, })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: externalAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(200); expect(res.body.access_token).toBeTruthy(); }); test('Token exchange JWT success', async () => { (fetch as unknown as jest.Mock).mockImplementation(() => ({ status: 200, text: () => `header.${encodeBase64Url(JSON.stringify({ email }))}.signature`, headers: { get: () => ContentType.JWT }, })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: externalAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(200); expect(res.body.access_token).toBeTruthy(); }); test('Token exchange unsupported content type', async () => { (fetch as unknown as jest.Mock).mockImplementation(() => ({ status: 200, headers: { get: () => ContentType.TEXT }, })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: externalAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Failed to verify code - unsupported content type: text/plain'); }); test('Too many requests', async () => { (fetch as unknown as jest.Mock).mockImplementation(() => ({ status: 429, headers: { get: () => ContentType.JSON }, })); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: externalAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(429); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Too Many Requests'); }); test('Token exchange missing client ID', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: '', subject_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token exchange missing client identity provider', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: client.id, subject_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid client'); }); test('Token exchange missing subject token', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: externalAuthClient.id, subject_token: '', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid subject_token'); }); test('Token exchange unknown subject token type', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.Saml1Token, client_id: externalAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid subject_token_type'); }); test('Token exchange invalid external URL', async () => { (fetch as unknown as jest.Mock).mockClear(); const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: OAuthGrantType.TokenExchange, subject_token_type: OAuthTokenType.AccessToken, client_id: invalidAuthClient.id, subject_token: 'xyz', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); expect(res.body.error_description).toBe('Invalid user info URL - check your identity provider configuration'); expect(fetch).not.toHaveBeenCalled(); }); test('FHIRcast scopes added to client credentials flow', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, scope: 'openid fhircast/Patient-open.read', }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); expect(res.body['hub.topic']).toBeDefined(); expect(res.body['hub.url']).toBeDefined(); }); test('FHIRcast scopes NOT added - should not have Hub topic or URL', async () => { const res = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'client_credentials', client_id: client.id, client_secret: client.secret, }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.access_token).toBeDefined(); expect(res.body['hub.topic']).not.toBeDefined(); expect(res.body['hub.url']).not.toBeDefined(); }); test('Refresh tokens disabled for super admins', async () => { // Create a super admin project const { project } = await createTestProject({ project: { superAdmin: true } }); // Create a test user const email = `test-${randomUUID()}@example.com`; const password = 'test-password'; await inviteUser({ project, resourceType: 'Practitioner', firstName: 'Test', lastName: 'Test', email, password, }); const res = await request(app).post('/auth/login').type('json').send({ email, password, codeChallenge: 'xyz', codeChallengeMethod: 'plain', scope: 'openid offline', // Request offline access }); expect(res.status).toBe(200); const res2 = await request(app).post('/oauth2/token').type('form').send({ grant_type: 'authorization_code', code: res.body.code, code_verifier: 'xyz', }); expect(res2.status).toBe(200); expect(res2.body.token_type).toBe('Bearer'); expect(res2.body.scope).toBe('openid offline'); expect(res2.body.expires_in).toBe(3600); expect(res2.body.id_token).toBeDefined(); expect(res2.body.access_token).toBeDefined(); expect(res2.body.refresh_token).toBeUndefined(); }); }); class MockJoseMultipleMatchingError extends Error { code: string; [Symbol.asyncIterator]!: () => AsyncIterableIterator<any>; constructor(message: string, code: string) { super(message); this.name = 'CustomError'; this.code = code; this[Symbol.asyncIterator] = async function* () { yield 'key1'; yield 'key2'; }; } }

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