Skip to content

Commit

Permalink
chore(elation): depracate password grant and add client credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
nckhell committed Dec 11, 2024
1 parent fb71d79 commit fc5ad79
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 55 deletions.
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
87 changes: 75 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,9 @@ import {
type AddVitalsResponseType,
PharmacySchema,
} from './types'
import { settingsSchema } from './validation/settings.zod'
import { elationCacheService } from './cache'
import { isEmpty } from 'lodash'
import { type z } from 'zod'

export class ElationDataWrapper extends DataWrapper {
public async findAppointments(
Expand Down Expand Up @@ -306,7 +310,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 +325,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 +504,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.

0 comments on commit fc5ad79

Please sign in to comment.