// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { randomUUID } from 'crypto';
import { generateKeyPair, SignJWT } from 'jose';
import { initAppServices, shutdownApp } from '../app';
import { loadTestConfig } from '../config/loader';
import type { MedplumServerConfig } from '../config/types';
import {
generateAccessToken,
generateIdToken,
generateRefreshToken,
generateSecret,
getSigningKey,
initKeys,
verifyJwt,
} from './keys';
describe('Keys', () => {
beforeAll(async () => {
const config = await loadTestConfig();
await initAppServices(config);
});
afterAll(async () => {
await shutdownApp();
});
test('Missing issuer', async () => {
const config = await loadTestConfig();
delete (config as any).issuer;
try {
await initKeys(config);
fail('Expected error');
} catch (err) {
expect((err as Error).message).toStrictEqual('Missing issuer');
}
});
test('Generate before initialized', async () => {
const config = await loadTestConfig();
expect.assertions(2);
try {
await initKeys(undefined as unknown as MedplumServerConfig);
} catch (err) {
expect((err as Error).message).toStrictEqual('Invalid server configuration');
}
try {
await generateIdToken({ iss: config.issuer, login_id: '123', nonce: randomUUID() });
} catch (err) {
expect((err as Error).message).toStrictEqual('Signing key not initialized');
}
});
test('Missing issuer', async () => {
const config = await loadTestConfig();
expect.assertions(3);
try {
await initKeys({} as unknown as MedplumServerConfig);
} catch (err) {
expect((err as Error).message).toStrictEqual('Missing issuer');
}
try {
await generateIdToken({ iss: config.issuer, login_id: '123', nonce: randomUUID() });
} catch (err) {
expect((err as Error).message).toStrictEqual('Signing key not initialized');
}
try {
await verifyJwt('xyz');
} catch (err) {
expect((err as Error).message).toStrictEqual('Signing key not initialized');
}
});
test('Missing kid', async () => {
expect.assertions(1);
const config = await loadTestConfig();
await initKeys(config);
// Construct a broken JWT with empty "kid"
const accessToken = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: '', typ: 'JWT' })
.setIssuedAt()
.setIssuer(config.issuer)
.setAudience('my-audience')
.setExpirationTime('1h')
.sign(getSigningKey());
try {
await verifyJwt(accessToken);
} catch (err) {
expect((err as Error).message).toStrictEqual('Missing kid header');
}
});
test('Key not found', async () => {
expect.assertions(1);
const config = await loadTestConfig();
await initKeys(config);
// Construct a JWT with different key
const { privateKey } = await generateKeyPair('RS256');
const accessToken = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: 'my-kid', typ: 'JWT' })
.setIssuedAt()
.setIssuer(config.issuer)
.setAudience('my-audience')
.setExpirationTime('1h')
.sign(privateKey);
try {
await verifyJwt(accessToken);
} catch (err) {
expect((err as Error).message).toStrictEqual('Key not found');
}
});
test('Generate ID token', async () => {
const config = await loadTestConfig();
await initKeys(config);
const token = await generateIdToken({
iss: config.issuer,
login_id: '123',
nonce: randomUUID(),
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
});
test('Generate access token', async () => {
const config = await loadTestConfig();
await initKeys(config);
const token = await generateAccessToken({
iss: config.issuer,
login_id: '123',
username: 'username',
scope: 'scope',
profile: 'profile',
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
});
test('Generate refresh token', async () => {
const config = await loadTestConfig();
await initKeys(config);
const token = await generateRefreshToken({
iss: config.issuer,
login_id: '123',
refresh_secret: 'secret',
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
});
test('Tokens include NotBefore claim', async () => {
const config = await loadTestConfig();
await initKeys(config);
const currentTime = Math.floor(Date.now() / 1000);
// Test access token
const accessToken = await generateAccessToken({
iss: config.issuer,
login_id: '123',
username: 'username',
scope: 'scope',
profile: 'profile',
});
const accessResult = await verifyJwt(accessToken);
expect(accessResult.payload.nbf).toBeDefined();
expect(typeof accessResult.payload.nbf).toBe('number');
// nbf should be close to current time (within 5 seconds)
expect(Math.abs((accessResult.payload.nbf as number) - currentTime)).toBeLessThan(5);
// nbf should be close to iat (within 1 second)
expect(Math.abs((accessResult.payload.nbf as number) - (accessResult.payload.iat as number))).toBeLessThan(1);
// Test ID token
const idToken = await generateIdToken({
iss: config.issuer,
login_id: '123',
nonce: randomUUID(),
});
const idResult = await verifyJwt(idToken);
expect(idResult.payload.nbf).toBeDefined();
expect(typeof idResult.payload.nbf).toBe('number');
// Test refresh token
const refreshToken = await generateRefreshToken({
iss: config.issuer,
login_id: '123',
refresh_secret: 'secret',
});
const refreshResult = await verifyJwt(refreshToken);
expect(refreshResult.payload.nbf).toBeDefined();
expect(typeof refreshResult.payload.nbf).toBe('number');
});
test('Generate secret', () => {
expect(generateSecret(16)).toHaveLength(32);
expect(generateSecret(32)).toHaveLength(64);
});
test('Generate access token with email', async () => {
const config = await loadTestConfig();
await initKeys(config);
const userEmail = 'test@example.com';
const token = await generateAccessToken({
iss: config.issuer,
login_id: '123',
username: 'username',
scope: 'openid profile email',
profile: 'Patient/123',
email: userEmail,
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
expect(result.payload.email).toStrictEqual(userEmail);
});
test('Generate access token without email', async () => {
const config = await loadTestConfig();
await initKeys(config);
const token = await generateAccessToken({
iss: config.issuer,
login_id: '123',
username: 'username',
scope: 'openid profile',
profile: 'Patient/123',
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
expect(result.payload.email).toBeUndefined();
});
test('Generate ID token with email', async () => {
const config = await loadTestConfig();
await initKeys(config);
const userEmail = 'test@example.com';
const token = await generateIdToken({
iss: config.issuer,
login_id: '123',
nonce: randomUUID(),
email: userEmail,
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
expect(result.payload.email).toStrictEqual(userEmail);
});
test('Generate ID token without email', async () => {
const config = await loadTestConfig();
await initKeys(config);
const token = await generateIdToken({
iss: config.issuer,
login_id: '123',
nonce: randomUUID(),
});
expect(token).toBeDefined();
const result = await verifyJwt(token);
expect(result.payload.login_id).toStrictEqual('123');
expect(result.payload.email).toBeUndefined();
});
});