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

ET-577: Elation - deprecate password grant and add client credentials #533

Merged
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
16 changes: 13 additions & 3 deletions extensions/elation/actions/findAppointments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
type DataPointDefinition,
type Field,
Category,
validate,
} from '@awell-health/extensions-core'
import { type settings } from '../settings'
import { SettingsValidationSchema, type settings } from '../settings'
import { makeAPIClient } from '../client'
import { FindAppointmentSchema } from '../validation/appointment.zod'
import { FindAppointmentFieldSchema } from '../validation/appointment.zod'
import { z } from 'zod'

const fields = {
patientId: {
Expand Down Expand Up @@ -77,9 +79,17 @@ export const findAppointments: Action<
previewable: true,
dataPoints,
onActivityCreated: async (payload, onComplete, onError): Promise<void> => {
const { fields, settings } = FindAppointmentSchema.parse(payload)
const { fields, settings } = validate({
schema: z.object({
fields: FindAppointmentFieldSchema,
settings: SettingsValidationSchema,
}),
payload,
})

const client = makeAPIClient(settings)
const resp = await client.findAppointments(fields)

await onComplete({
data_points: {
appointments: JSON.stringify(resp),
Expand Down
9 changes: 4 additions & 5 deletions extensions/elation/actions/getPatient/getPatient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export const getPatient: Action<
key: 'getPatient',
category: Category.EHR_INTEGRATIONS,
title: 'Get Patient',
description:
'Retrieve a patient profile using Elation`s patient API.',
description: 'Retrieve a patient profile using Elation`s patient API.',
fields,
previewable: true,
dataPoints,
Expand All @@ -32,7 +31,6 @@ export const getPatient: Action<

const patientInfo = await api.getPatient(fields.patientId)


await onComplete({
data_points: {
firstName: patientInfo.first_name,
Expand Down Expand Up @@ -62,8 +60,9 @@ export const getPatient: Action<
previousFirstName: patientInfo.previous_first_name,
previousLastName: patientInfo.previous_last_name,
status: patientInfo.patient_status.status,
preferredServiceLocationId:
String(patientInfo.preferred_service_location),
preferredServiceLocationId: String(
patientInfo.preferred_service_location
),
patientObject: JSON.stringify(patientInfo),
},
})
Expand Down
86 changes: 74 additions & 12 deletions extensions/elation/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {
APIClient,
DataWrapper,
OAuthClientCredentials,
type OAuthGrantClientCredentialsRequest,
type OAuthGrantRequest,
OAuthPassword,
type DataWrapperCtor,
type OAuthGrantPasswordRequest,
} from '@awell-health/extensions-core'
import { type settings } from './settings'
import { SettingsValidationSchema } from './settings'
import {
type FindAppointmentsParams,
type AppointmentInput,
Expand Down Expand Up @@ -39,8 +42,8 @@ import {
type AddVitalsResponseType,
PharmacySchema,
} from './types'
import { settingsSchema } from './validation/settings.zod'
import { elationCacheService } from './cache'
import { isEmpty } from 'lodash'

export class ElationDataWrapper extends DataWrapper {
public async findAppointments(
Expand Down Expand Up @@ -306,7 +309,7 @@ export class ElationDataWrapper extends DataWrapper {

interface ElationAPIClientConstructorProps {
authUrl: string
requestConfig: Omit<OAuthGrantPasswordRequest, 'grant_type'>
requestConfig: Omit<OAuthGrantRequest, 'grant_type'>
baseUrl: string
}

Expand All @@ -321,14 +324,32 @@ export class ElationAPIClient extends APIClient<ElationDataWrapper> {
requestConfig,
...opts
}: ElationAPIClientConstructorProps) {
super({
...opts,
auth: new OAuthPassword({
const getAuth = (): OAuthPassword | OAuthClientCredentials => {
if ('username' in requestConfig && 'password' in requestConfig) {
return new OAuthPassword({
auth_url: authUrl,
request_config: requestConfig as Omit<
OAuthGrantPasswordRequest,
'grant_type'
>,
cacheService: elationCacheService,
useHeaderInAuthorization: true,
})
}

return new OAuthClientCredentials({
auth_url: authUrl,
request_config: requestConfig,
request_config: requestConfig satisfies Omit<
OAuthGrantClientCredentialsRequest,
'grant_type'
>,
cacheService: elationCacheService,
useHeaderInAuthorization: true,
}),
})
}

super({
...opts,
auth: getAuth(),
})
}

Expand Down Expand Up @@ -482,14 +503,55 @@ export class ElationAPIClient extends APIClient<ElationDataWrapper> {
}

export const makeAPIClient = (
payloadSettings: Record<keyof typeof settings, string | undefined>
settings: Record<string, unknown>
): ElationAPIClient => {
const { base_url, auth_url, ...auth_request_settings } =
settingsSchema.parse(payloadSettings)
SettingsValidationSchema.parse(settings)

/**
* Determines the OAuth grant type based on the provided settings.
* Currently, we support both the "password" and "client_credentials" grant types for backward compatibility.
* - "password" grant is still supported to avoid breaking existing care flows that rely on it.
* - "client_credentials" grant is the preferred method for authentication as per the latest Elation API guidance.
* Once all existing care flows are migrated, support for the "password" grant can be deprecated.
*/
const getGrantType = (): 'password' | 'client_credentials' => {
if (
isEmpty(auth_request_settings.username) ||
isEmpty(auth_request_settings.password)
)
return 'client_credentials'

return 'password'
}

const grantType = getGrantType()

/**
* Builds the appropriate request configuration based on the selected grant type.
* - For "client_credentials", only the client ID and client secret are required.
* - For "password", username and password are included for compatibility with older flows.
*/
const getRequestConfig = ():
| Omit<OAuthGrantClientCredentialsRequest, 'grant_type'>
| Omit<OAuthGrantPasswordRequest, 'grant_type'> => {
if (grantType === 'client_credentials')
return {
client_id: auth_request_settings.client_id,
client_secret: auth_request_settings.client_secret,
} satisfies Omit<OAuthGrantClientCredentialsRequest, 'grant_type'>

return {
client_id: auth_request_settings.client_id,
client_secret: auth_request_settings.client_secret,
username: auth_request_settings.username as string,
password: auth_request_settings.password as string,
} satisfies Omit<OAuthGrantPasswordRequest, 'grant_type'>
}

return new ElationAPIClient({
authUrl: auth_url,
requestConfig: auth_request_settings,
baseUrl: base_url,
requestConfig: getRequestConfig(),
})
}
19 changes: 13 additions & 6 deletions extensions/elation/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ export const settings = {
key: 'username',
label: 'Username',
obfuscated: false,
description: 'The API username for OAuth2 password authentication.',
required: true,
description:
'⚠️ Deprecated: Elation now uses client credentials authentication. This setting is no longer required and should be removed from your settings.',
required: false,
},
password: {
key: 'password',
label: 'Password',
obfuscated: true,
description: 'The API password for OAuth2 password authentication.',
required: true,
description:
'⚠️ Deprecated: Elation now uses client credentials authentication. This setting is no longer required and should be removed from your settings.',
required: false,
},
} satisfies Record<string, Setting>

Expand All @@ -51,6 +53,11 @@ export const SettingsValidationSchema = z.object({
auth_url: z.string().min(1),
client_id: z.string().min(1),
client_secret: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
/**
* Elation now uses client credentials authentication.
* We don't remove the settings just yet for backward compatibility for existing care flows.
* See https://linear.app/awell/issue/ET-577/elation-extension-make-username-and-password-optional-in-auth
*/
username: z.string().optional(),
password: z.string().optional(),
} satisfies Record<keyof typeof settings, ZodTypeAny>)
2 changes: 0 additions & 2 deletions extensions/elation/types/appointment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import {
type statusSchema,
type appointmentSchema,
type FindAppointmentFieldSchema,
type FindAppointmentSchema,
} from '../validation/appointment.zod'

export type AppointmentInput = z.infer<typeof appointmentSchema>
export type FindAppointmentFields = z.input<typeof FindAppointmentFieldSchema>
export type FindAppointment = z.infer<typeof FindAppointmentSchema>
/**
* There is a difference between `input` and `output` objects in Elation,
* some fields are readonly (not in input), some have different structure.
Expand Down
5 changes: 0 additions & 5 deletions extensions/elation/validation/appointment.zod.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { DateTimeSchema, NumericIdSchema } from '@awell-health/extensions-core'
import { isNil } from 'lodash'
import * as z from 'zod'
import { settingsSchema } from './settings.zod'

const statusEnum = z.enum([
'Scheduled',
Expand Down Expand Up @@ -61,7 +60,3 @@ export const FindAppointmentFieldSchema = z
...(!isNil(data.event_type) && { event_type: 'appointment' }),
}
})
export const FindAppointmentSchema = z.object({
fields: FindAppointmentFieldSchema,
settings: settingsSchema,
})
22 changes: 0 additions & 22 deletions extensions/elation/validation/settings.zod.ts

This file was deleted.

Loading