-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoauth.js
More file actions
324 lines (275 loc) · 9.79 KB
/
oauth.js
File metadata and controls
324 lines (275 loc) · 9.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
// ###########################################################################################
// # The Main Oauth functionality is defined in this section.
// # These steps must happen while the callback server is listening to receive the
// # callback from Epic.
// #
// # Step 1: Request the URIs from Epic's FHIR metadata.
// # This will fetch the authorization and token URIs needed for the OAuth flow.
// # This step can technically be skipped if the URIs are known and static.
// #
// # Step 2: Request Epic authorization by redirecting the browser with requestEpicAuthorization.
// # This will redirect the browser window for the user to log in and authorize the app.
// #
// # Step 3: Exchange the authorization code for an access token.
// # This will be done in the callback server when Epic redirects back to the app.
// ###########################################################################################
import Bluebird from 'bluebird'
import fs from 'fs'
import jwt from 'jsonwebtoken'
import JwksClient from 'jwks-client'
import { jwtDecode } from 'jwt-decode'
import _ from 'lodash'
import url from 'url'
import { logger } from './logger.js'
import { successPage } from './pages/authSuccess.js'
import { generateState, validateState } from './state-manager.js'
import requests from './test-requests.json' with { type: 'json' }
const {
EPIC_BASE_URL,
CLIENT_ID,
CLIENT_SECRET = undefined,
HOST = 'localhost',
PORT = 4005,
CALLBACK_PATH,
SCOPE = 'openid fhirUser profile',
} = process.env
const REDIRECT_URI = `http://${HOST}:${PORT}${CALLBACK_PATH}`
const METADATA_URL = `${EPIC_BASE_URL}/metadata`
const OPENID_CONFIG_URL = `${EPIC_BASE_URL}/.well-known/openid-configuration`
const FETCH_PROFILE = process.env.FETCH_PROFILE === 'true'
const FETCH_ADDITIONAL_RESOURCES = process.env.FETCH_ADDITIONAL_RESOURCES === 'true'
const RESPONSE_DIRECTORY = './responses'
/**
* Global data fetched from Epic's metadata and Openid config
*/
let authUri, tokenUri, jwksUri, openIdIssuer
/**
* Fetch metadata and OpenID config.
*
* From the metadata, store the authorization and token uris.
* These will be used to initialize authorization and exchange
* auth code for a token, respectively.
*
* From the openID config, store the JWKS URI and the issuer.
* The JWKS URI stores Epic's public key used to verify OpenID
* tokens. The issuer is typically Epic's base url and will also
* be used when verifying the OpenID token.
* @returns {Promise<void>}
*/
export async function fetchURIs() {
logger.info('Fetching Epic metadata...')
const metadataRes = await fetch(METADATA_URL, {
headers: { Accept: 'application/json' }
})
const metadata = await metadataRes.json()
const extensions = _.get(metadata, 'rest[0].security.extension[0].extension')
authUri = extensions[0].valueUri
tokenUri = extensions[1].valueUri
logger.debug(`Authorization URI: ${authUri}`)
logger.debug(`Token URI: ${tokenUri}`)
const openIdConfigRes = await fetch(OPENID_CONFIG_URL, {
headers: { Accept: 'application/json' }
})
const openIdConfig = await openIdConfigRes.json()
jwksUri = _.get(openIdConfig, 'jwks_uri')
openIdIssuer = _.get(openIdConfig, 'issuer')
logger.debug(`JWKS URI: ${tokenUri}`)
logger.debug(`OpenID Issuer: ${openIdIssuer}`)
}
/**
* Validate the ID token using Epic's JWKS URI.
*
* @param {string} token - The ID token to validate.
* @returns {Promise<object>} - The decoded and validated token payload.
*/
async function validateIdToken(token) {
const decodedHeader = jwtDecode(token, { header: true })
const { kid } = decodedHeader
logger.debug('id token kid:', kid)
// Fetch the signing key from the JWKS URI.
const client = JwksClient({ jwksUri })
const getSigningKeyAsync = Bluebird.promisify(client.getSigningKey.bind(client))
const key = await getSigningKeyAsync(kid)
const publicKey = key.publicKey
logger.debug('Epic public key:', publicKey)
return jwt.verify(
token,
publicKey,
{
algorithms: ['RS256'],
issuer: openIdIssuer,
audience: CLIENT_ID
}
)
}
/**
* Request Epic authorization by redirecting the browser.
* @returns {Promise<void>}
*/
export async function requestEpicAuthorization(res, req) {
logger.info('Redirecting to Epic for authorization...')
const parsedUrl = url.parse(req.url, true)
const launch = parsedUrl.query.launch
const iss = parsedUrl.query.iss
const scope = launch ? `${SCOPE} launch` : SCOPE
if (launch) {
logger.info('Received embedded launch request')
logger.debug('URL:', req.url)
}
const baseUrl = authUri
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
scope,
redirect_uri: REDIRECT_URI,
state: generateState(),
...iss && { aud: iss },
...launch && { launch },
...CLIENT_SECRET && { secret: CLIENT_SECRET },
}).toString()
const authUrl = `${baseUrl}?${params}`
logger.debug('Opening Epic authorization URL:', authUrl)
res.writeHead(302, { Location: authUrl })
res.end()
}
/**
* Parse code from Epic OAuth callback and request an access token.
*
* Validation is performed on the state variable in the Epic response to prevent against csrf attacks.
* If an error is present in the Epic response, it will be parsed and shown in the browser.
* The access token will be stored in a file to be used for further access to FHIR endpoints.
* If desired, a follow-up request will be made to Epic to get the authenticated user's FHIR profile.
*
* @param req
* @param res
* @returns {Promise<void>}
*/
export async function exchangeCodeForToken(req, res) {
logger.info('Received callback from Epic')
logger.debug('URL:', req.url)
const parsedUrl = url.parse(req.url, true)
const returnedState = parsedUrl.query.state
const code = parsedUrl.query.code
const error = parsedUrl.query.error
const errorDescription = parsedUrl.query.error_description
// Error handling and state validation
try {
if (!validateState(returnedState)) {
throw new Error(`Invalid state parameter: expected ${state}, but got ${returnedState}`, { cause: 'Invalid State' })
}
if (error) {
throw new Error(errorDescription, { cause: error })
}
if (!code) {
throw new Error('Missing authorization code', { cause: 'Missing code' })
}
logger.debug(`Received code for token ${code}`)
logger.info('Exchanging code for token...')
const baseUrl = tokenUri
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
...CLIENT_SECRET && { secret: CLIENT_SECRET },
}).toString()
logger.debug('Exchanging code for token at:', baseUrl)
logger.debug('Body of token request:', params)
const tokenResponse = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${await tokenResponse.text()}`, { cause: 'Failed to get token from Epic' })
}
const token = await tokenResponse.json()
// Validate id token using Epic's signing key
await validateIdToken(token.id_token)
// Write token to file (not necessary for production code)
await fs.writeFileSync('./token.json', JSON.stringify(token, null, 2))
logger.info('Access token saved to token.json')
// Optionally fetch the user's profile
let profile
if (FETCH_PROFILE) {
profile = await requestProfile(token)
profile && logger.debug('Profile information retrieved:', profile)
}
res.writeHead(200, { 'Content-Type': 'text/html' }).end(successPage(token, profile))
// Optionally make additional requests
if (FETCH_ADDITIONAL_RESOURCES) {
await testRequests(token)
}
} catch (error) {
logger.error('Token exchange failed:', error.message)
return res.writeHead(500).end(`${error.cause}: ${error.message}`)
}
}
/**
* Make a request to the fhirUser url stored in the authorization token as part of the OAuth flow.
*
* @param token
* @returns {Promise<any>}
*/
export async function requestProfile(token) {
logger.info('Requesting authenticated user\'s profile...')
const {
id_token,
access_token
} = token
const { fhirUser: url } = jwtDecode(id_token)
logger.debug('Profile URL:', url)
const res = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
Authorization: `Bearer ${access_token}`,
},
})
if (!res.ok) {
logger.error(`Profile request failed: ${res.status} ${await res.text()}`)
return
}
return res.json()
}
/**
* Read from the requests json file to send requests to Epic.
*
* Results will be stored in timestamped files with the resource name in the file name.
*
* @param token
* @returns {Promise<void>}
*/
async function testRequests(token) {
const {
access_token
} = token
if (!fs.existsSync(RESPONSE_DIRECTORY)) {
// If it doesn't exist, create the directory
fs.mkdirSync(RESPONSE_DIRECTORY)
}
return Bluebird.each(requests, async (request) => {
const {
resource,
params = {},
} = request
const encodedParams = new URLSearchParams(params).toString()
const url = `${EPIC_BASE_URL}/${resource}?${encodedParams}`
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${access_token}`,
},
})
if (!res.ok) {
logger.error('Error when requesting', url, ':', await res.text())
return
}
const jsonRes = await res.json()
logger.debug(jsonRes)
await fs.writeFileSync(`${RESPONSE_DIRECTORY}/${Date.now()}-${resource}.json`, JSON.stringify(jsonRes, null, 2))
logger.info('Wrote response for', resource)
})
}