Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Configure refresh token expiry #4525

Merged
merged 8 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/definitions/dist/fhir/r4/fhir.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -59633,6 +59633,10 @@
"identityProvider": {
"description": "Optional external Identity Provider (IdP) for the client application.",
"$ref": "#/definitions/IdentityProvider"
},
"refreshTokenLifetime": {
"description": "Optional configuration to set the refresh token duration",
"$ref": "#/definitions/string"
}
},
"additionalProperties": false,
Expand Down
21 changes: 21 additions & 0 deletions packages/definitions/dist/fhir/r4/profiles-medplum.json
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,27 @@
"min" : 0,
"max" : "1"
}
},
{
"id" : "ClientApplication.refreshTokenLifetime",
"path" : "ClientApplication.refreshTokenLifetime",
"definition" : "Optional configuration to set the refresh token duration",
"min" : 0,
"max" : "1",
"type" : [{
"code" : "string"
}],
"constraint" : [{
"key" : "clapp-1",
"severity" : "error",
"human" : "Token lifetime must be a valid string representing time duration (eg. 2w, 1h)",
"expression" : "$this.matches('^[0-9]+[smhdwy]$')"
}],
"base": {
"path" : "ClientApplication.refreshTokenLifetime",
"min" : 0,
"max" : "1"
}
}
]
}
Expand Down
5 changes: 5 additions & 0 deletions packages/fhirtypes/dist/ClientApplication.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,9 @@ export interface ClientApplication {
* Optional external Identity Provider (IdP) for the client application.
*/
identityProvider?: IdentityProvider;

/**
* Optional configuration to set the refresh token duration
*/
refreshTokenLifetime?: string;
}
2 changes: 2 additions & 0 deletions packages/server/src/admin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface CreateClientRequest {
readonly redirectUri?: string;
readonly accessPolicy?: Reference<AccessPolicy>;
readonly identityProvider?: IdentityProvider;
readonly refreshTokenLifetime?: string;
}

export async function createClient(repo: Repository, request: CreateClientRequest): Promise<ClientApplication> {
Expand All @@ -55,6 +56,7 @@ export async function createClient(repo: Repository, request: CreateClientReques
description: request.description,
redirectUri: request.redirectUri,
identityProvider: request.identityProvider,
refreshTokenLifetime: request.refreshTokenLifetime,
});

const systemRepo = getSystemRepo();
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/auth/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export async function registerNew(request: RegisterRequest): Promise<RegisterRes
...login,
membership: createReference(membership as ProjectMembership),
},
createReference(profile as ProfileResource)
createReference(profile as ProfileResource),
client.refreshTokenLifetime
);

return {
Expand Down
15 changes: 12 additions & 3 deletions packages/server/src/oauth/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface MedplumRefreshTokenClaims extends MedplumBaseClaims {
* This is the algorithm used by AWS Cognito and Auth0.
*/
const ALG = 'RS256';
const DEFAULT_REFRESH_LIFETIME = '2w';

let issuer: string | undefined;
const publicKeys: Record<string, KeyLike> = {};
Expand Down Expand Up @@ -205,10 +206,13 @@ export function generateAccessToken(
/**
* Generates a refresh token JWT.
* @param claims - The refresh token claims.
* @param refreshLifetime - The refresh token duration.
* @returns A well-formed JWT that can be used as a refresh token.
*/
export function generateRefreshToken(claims: MedplumRefreshTokenClaims): Promise<string> {
return generateJwt('2w', claims);
export function generateRefreshToken(claims: MedplumRefreshTokenClaims, refreshLifetime?: string): Promise<string> {
const duration = refreshLifetime ?? DEFAULT_REFRESH_LIFETIME;

return generateJwt(duration, claims);
}

/**
Expand All @@ -217,11 +221,16 @@ export function generateRefreshToken(claims: MedplumRefreshTokenClaims): Promise
* @param claims - The key/value pairs to include in the payload section.
* @returns Promise to generate and sign the JWT.
*/
async function generateJwt(exp: '1h' | '2w', claims: JWTPayload): Promise<string> {
async function generateJwt(exp: string, claims: JWTPayload): Promise<string> {
if (!signingKey || !issuer) {
throw new Error('Signing key not initialized');
}

const regex = /^[0-9]+[smhdwy]$/;
if (!regex.test(exp)) {
throw new Error('Invalid token duration');
}

return new SignJWT(claims)
.setProtectedHeader({ alg: ALG, kid: signingKeyId, typ: 'JWT' })
.setIssuedAt()
Expand Down
77 changes: 74 additions & 3 deletions packages/server/src/oauth/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
parseSearchRequest,
} from '@medplum/core';
import { AccessPolicy, ClientApplication, Login, Project, SmartAppLaunch } from '@medplum/fhirtypes';
import { randomUUID } from 'crypto';
import express from 'express';
import { 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';
Expand All @@ -19,7 +19,7 @@ import { setPassword } from '../auth/setpassword';
import { loadTestConfig, MedplumServerConfig } from '../config';
import { getSystemRepo } from '../fhir/repo';
import { createTestProject, withTestContext } from '../test.setup';
import { generateSecret } from './keys';
import { generateSecret, verifyJwt } from './keys';
import { hashCode } from './token';

jest.mock('jose', () => {
Expand All @@ -32,7 +32,8 @@ jest.mock('jose', () => {
const payload = core.parseJWTPayload(credential);
if (payload.invalid) {
throw new Error('Verification failed');
} else if (payload.multipleMatching) {
}
if (payload.multipleMatching) {
count = payload.successVerified ? count + 1 : 0;
let error: MockJoseMultipleMatchingError;
if (count <= 1) {
Expand Down Expand Up @@ -1133,6 +1134,76 @@ describe('OAuth2 Token', () => {
expect(res5.body).toMatchObject({ error: 'invalid_request', error_description: 'Invalid token' });
});

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 as string,
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 as string,
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).toEqual((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';
Expand Down
31 changes: 25 additions & 6 deletions packages/server/src/oauth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ async function handleClientCredentials(req: Request, res: Response): Promise<voi
return;
}

await sendTokenResponse(res, login, membership);
await sendTokenResponse(res, login, membership, client.refreshTokenLifetime);
}

/**
Expand Down Expand Up @@ -247,7 +247,7 @@ async function handleAuthorizationCode(req: Request, res: Response): Promise<voi
}

const membership = await systemRepo.readReference<ProjectMembership>(login.membership);
await sendTokenResponse(res, login, membership);
await sendTokenResponse(res, login, membership, client?.refreshTokenLifetime);
}

/**
Expand Down Expand Up @@ -311,6 +311,19 @@ async function handleRefreshToken(req: Request, res: Response): Promise<void> {
}
}

let client: ClientApplication | undefined;
if (login.client) {
const clientId = resolveId(login.client) ?? '';
try {
client = await systemRepo.readResource<ClientApplication>('ClientApplication', clientId);
} catch (err) {
sendTokenError(res, 'invalid_request', 'Invalid client');
return;
}
}

const refreshTokenLifetime = client?.refreshTokenLifetime;

// Refresh token rotation
// Generate a new refresh secret and update the login
const updatedLogin = await systemRepo.updateResource<Login>({
Expand All @@ -324,7 +337,7 @@ async function handleRefreshToken(req: Request, res: Response): Promise<void> {
login.membership as Reference<ProjectMembership>
);

await sendTokenResponse(res, updatedLogin, membership);
await sendTokenResponse(res, updatedLogin, membership, refreshTokenLifetime);
}

/**
Expand Down Expand Up @@ -411,7 +424,7 @@ export async function exchangeExternalAuthToken(
login.membership as Reference<ProjectMembership>
);

await sendTokenResponse(res, login, membership);
await sendTokenResponse(res, login, membership, client.refreshTokenLifetime);
}

/**
Expand Down Expand Up @@ -561,10 +574,16 @@ async function validateClientIdAndSecret(
* @param res - The HTTP response.
* @param login - The user login.
* @param membership - The project membership.
* @param refreshLifetime - The refresh token duration.
*/
async function sendTokenResponse(res: Response, login: Login, membership: ProjectMembership): Promise<void> {
async function sendTokenResponse(
res: Response,
login: Login,
membership: ProjectMembership,
refreshLifetime?: string
): Promise<void> {
const config = getConfig();
const tokens = await getAuthTokens(login, membership.profile as Reference<ProfileResource>);
const tokens = await getAuthTokens(login, membership.profile as Reference<ProfileResource>, refreshLifetime);
let patient = undefined;
let encounter = undefined;

Expand Down
19 changes: 13 additions & 6 deletions packages/server/src/oauth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,11 @@ export async function setLoginScope(login: Login, scope: string): Promise<Login>
});
}

export async function getAuthTokens(login: Login, profile: Reference<ProfileResource>): Promise<TokenResult> {
export async function getAuthTokens(
login: Login,
profile: Reference<ProfileResource>,
refreshLifetime?: string
): Promise<TokenResult> {
const clientId = login.client && resolveId(login.client);
const userId = resolveId(login.user);
if (!userId) {
Expand Down Expand Up @@ -535,11 +539,14 @@ export async function getAuthTokens(login: Login, profile: Reference<ProfileReso
});

const refreshToken = login.refreshSecret
? await generateRefreshToken({
client_id: clientId,
login_id: login.id as string,
refresh_secret: login.refreshSecret,
})
? await generateRefreshToken(
{
client_id: clientId,
login_id: login.id as string,
refresh_secret: login.refreshSecret,
},
refreshLifetime
)
: undefined;

return {
Expand Down
Loading