handle authorize claim for confidential sources.

update BlueButton image to use Medicare logo.
This commit is contained in:
Jason Kulatunga 2022-09-24 14:19:25 -07:00
parent 4afbd6f834
commit 5f2f99a202
11 changed files with 134 additions and 76 deletions

View File

@ -6,27 +6,29 @@ type SourceType string
const ( const (
SourceTypeManual SourceType = "manual" SourceTypeManual SourceType = "manual"
SourceTypeAetna SourceType = "aetna" SourceTypeAetna SourceType = "aetna"
SourceTypeAthena SourceType = "athena" SourceTypeAthena SourceType = "athena"
SourceTypeAnthem SourceType = "anthem" SourceTypeAnthem SourceType = "anthem"
SourceTypeCedarSinai SourceType = "cedarssinai" SourceTypeCareEvolution SourceType = "careevolution"
SourceTypeCerner SourceType = "cerner" SourceTypeCedarSinai SourceType = "cedarssinai"
SourceTypeCigna SourceType = "cigna" SourceTypeCerner SourceType = "cerner"
SourceTypeCommonSpirit SourceType = "commonspirit" SourceTypeCigna SourceType = "cigna"
SourceTypeDeltaDental SourceType = "deltadental" SourceTypeCommonSpirit SourceType = "commonspirit"
SourceTypeDignityHealth SourceType = "dignityhealth" SourceTypeDeltaDental SourceType = "deltadental"
SourceTypeEpic SourceType = "epic" SourceTypeDignityHealth SourceType = "dignityhealth"
SourceTypeHCAHealthcare SourceType = "hcahealthcare" SourceTypeEpic SourceType = "epic"
SourceTypeHumana SourceType = "humana" SourceTypeHealthIT SourceType = "healthit"
SourceTypeKaiser SourceType = "kaiser" SourceTypeHCAHealthcare SourceType = "hcahealthcare"
SourceTypeLogica SourceType = "logica" SourceTypeHumana SourceType = "humana"
SourceTypeMetlife SourceType = "metlife" SourceTypeKaiser SourceType = "kaiser"
SourceTypeProvidence SourceType = "providence" SourceTypeLogica SourceType = "logica"
SourceTypeStanford SourceType = "stanford" SourceTypeMetlife SourceType = "metlife"
SourceTypeSutter SourceType = "sutter" SourceTypeProvidence SourceType = "providence"
SourceTypeTrinity SourceType = "trinity" SourceTypeStanford SourceType = "stanford"
SourceTypeUCSF SourceType = "ucsf" SourceTypeSutter SourceType = "sutter"
SourceTypeUnitedHealthcare SourceType = "unitedhealthcare" SourceTypeTrinity SourceType = "trinity"
SourceTypeVeteransHealthAdministration SourceType = "bluebutton" SourceTypeUCSF SourceType = "ucsf"
SourceTypeVerity SourceType = "verity" SourceTypeUnitedHealthcare SourceType = "unitedhealthcare"
SourceTypeBlueButtonMedicare SourceType = "bluebutton"
SourceTypeVerity SourceType = "verity"
) )

View File

@ -9,5 +9,6 @@ type MetadataSource struct {
Display string `json:"display"` Display string `json:"display"`
Category []string `json:"category"` Category []string `json:"category"`
Supported bool `json:"enabled"` Supported bool `json:"enabled"`
Confidential bool `json:"confidential"`
} }

View File

@ -30,6 +30,8 @@ type Source struct {
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
CodeChallenge string `json:"code_challenge"` CodeChallenge string `json:"code_challenge"`
CodeVerifier string `json:"code_verifier"` CodeVerifier string `json:"code_verifier"`
Confidential bool `json:"confidential"`
} }
/* /*

View File

@ -10,8 +10,9 @@ import (
func GetMetadataSource(c *gin.Context) { func GetMetadataSource(c *gin.Context) {
metadataSource := map[string]models.MetadataSource{ metadataSource := map[string]models.MetadataSource{
string(pkg.SourceTypeLogica): {Display: "Logica (Sandbox)", SourceType: pkg.SourceTypeLogica, Category: []string{"Sandbox"}, Supported: true}, string(pkg.SourceTypeLogica): {Display: "Logica (Sandbox)", SourceType: pkg.SourceTypeLogica, Category: []string{"Sandbox"}, Supported: true},
string(pkg.SourceTypeAthena): {Display: "Athena (Sandbox)", SourceType: pkg.SourceTypeAthena, Category: []string{"Sandbox"}, Supported: true}, string(pkg.SourceTypeAthena): {Display: "Athena (Sandbox)", SourceType: pkg.SourceTypeAthena, Category: []string{"Sandbox"}, Supported: true},
string(pkg.SourceTypeHealthIT): {Display: "HealthIT (Sandbox)", SourceType: pkg.SourceTypeHealthIT, Category: []string{"Sandbox"}, Supported: true},
// enabled // enabled
string(pkg.SourceTypeAetna): {Display: "Aetna", SourceType: pkg.SourceTypeAetna, Category: []string{"Insurance"}, Supported: true}, string(pkg.SourceTypeAetna): {Display: "Aetna", SourceType: pkg.SourceTypeAetna, Category: []string{"Insurance"}, Supported: true},
@ -20,9 +21,10 @@ func GetMetadataSource(c *gin.Context) {
//TODO: infinite pagination for Encounters?? //TODO: infinite pagination for Encounters??
string(pkg.SourceTypeCerner): {Display: "Cerner (Sandbox)", SourceType: pkg.SourceTypeCerner, Category: []string{"Sandbox"}, Supported: true}, string(pkg.SourceTypeCerner): {Display: "Cerner (Sandbox)", SourceType: pkg.SourceTypeCerner, Category: []string{"Sandbox"}, Supported: true},
//does not support PKCE //does not support PKCE/Public Clients
string(pkg.SourceTypeVeteransHealthAdministration): {Display: "Veterans Health (BlueButton)", SourceType: pkg.SourceTypeVeteransHealthAdministration, Category: []string{"Hospital"}, Supported: false}, string(pkg.SourceTypeBlueButtonMedicare): {Display: "Medicare/VA Health (BlueButton)", SourceType: pkg.SourceTypeBlueButtonMedicare, Category: []string{"Hospital"}, Supported: false},
string(pkg.SourceTypeEpic): {Display: "Epic (Sandbox)", SourceType: pkg.SourceTypeEpic, Category: []string{"Sandbox"}, Supported: false}, string(pkg.SourceTypeEpic): {Display: "Epic (Sandbox)", SourceType: pkg.SourceTypeEpic, Category: []string{"Sandbox"}, Supported: false},
string(pkg.SourceTypeCareEvolution): {Display: "CareEvolution (Sandbox)", SourceType: pkg.SourceTypeCareEvolution, Category: []string{"Sandbox"}, Supported: false},
// pending // pending
string(pkg.SourceTypeAnthem): {Display: "Anthem", SourceType: pkg.SourceTypeAnthem, Category: []string{"Insurance"}}, string(pkg.SourceTypeAnthem): {Display: "Anthem", SourceType: pkg.SourceTypeAnthem, Category: []string{"Insurance"}},

View File

@ -20,4 +20,6 @@ export class Source {
expires_at: number expires_at: number
code_challenge: string code_challenge: string
code_verifier: string code_verifier: string
confidential: boolean
} }

View File

@ -1,6 +1,11 @@
export class AuthorizeClaim { export class AuthorizeClaim {
source_type: string source_type: string
state: string state: string
code: string code?: string
ttl: number ttl?: number
access_token?: string
refresh_token?: string
id_token?: string
expires_at?: number
} }

View File

@ -11,4 +11,6 @@ export class LighthouseSource {
scopes: string[] scopes: string[]
redirect_uri: string redirect_uri: string
aud: string aud: string
confidential: boolean
} }

View File

@ -83,7 +83,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button (click)="syncSource(modalSourceInfo.source)" type="button" class="btn btn-indigo">Sync</button> <button (click)="syncSource(modalSourceInfo.source)" type="button" class="btn btn-indigo">Sync</button>
<button type="button" class="btn btn-outline-light">Reconnect</button> <button (click)="connect($event, modalSourceInfo.source['source_type'])" type="button" class="btn btn-outline-light">Reconnect</button>
<button type="button" class="btn btn-outline-danger">Delete</button> <button type="button" class="btn btn-outline-danger">Delete</button>
<button type="button" class="btn btn-outline-light">Close</button> <button type="button" class="btn btn-outline-light">Close</button>
</div> </div>

View File

@ -77,13 +77,25 @@ export class MedicalSourcesComponent implements OnInit {
.subscribe(async (connectData: LighthouseSource) => { .subscribe(async (connectData: LighthouseSource) => {
console.log(connectData); console.log(connectData);
// https://github.com/panva/oauth4webapi/blob/8eba19eac408bdec5c1fe8abac2710c50bfadcc3/examples/public.ts
const codeVerifier = Oauth.generateRandomCodeVerifier();
const codeChallenge = await Oauth.calculatePKCECodeChallenge(codeVerifier);
const codeChallengeMethod = 'S256';
const state = this.uuidV4() const state = this.uuidV4()
const authorizationUrl = this.lighthouseApi.generatePKCESourceAuthorizeUrl(codeVerifier, codeChallenge, codeChallengeMethod, state, connectData) let authorizationUrl
//only set if this is not a "confidential" source.
let codeVerifier
let codeChallenge
let codeChallengeMethod
if(connectData.confidential){
authorizationUrl = this.lighthouseApi.generateConfidentialSourceAuthorizeUrl(state, connectData)
} else {
// https://github.com/panva/oauth4webapi/blob/8eba19eac408bdec5c1fe8abac2710c50bfadcc3/examples/public.ts
codeVerifier = Oauth.generateRandomCodeVerifier();
codeChallenge = await Oauth.calculatePKCECodeChallenge(codeVerifier);
codeChallengeMethod = 'S256';
authorizationUrl = this.lighthouseApi.generatePKCESourceAuthorizeUrl(codeVerifier, codeChallenge, codeChallengeMethod, state, connectData)
}
console.log('authorize url:', authorizationUrl.toString()); console.log('authorize url:', authorizationUrl.toString());
// open new browser window // open new browser window
@ -94,50 +106,20 @@ export class MedicalSourcesComponent implements OnInit {
console.log("claim response:", claimData) console.log("claim response:", claimData)
this.status[sourceType] = "token" this.status[sourceType] = "token"
//swap code for token let payload: any
let sub: string if(connectData.confidential){
let access_token: string
// @ts-expect-error // we should have an access_token (and optionally a refresh_token) in the claim
const client: oauth.Client = { payload = claimData
client_id: connectData.client_id,
token_endpoint_auth_method: 'none', } else {
payload = await this.swapOauthPKCEToken(state, codeVerifier, authorizationUrl, connectData, claimData)
} }
//check if the oauth_token_endpoint_auth_methods_supported field is set
if(connectData.oauth_token_endpoint_auth_methods_supported){
let auth_methods = connectData.oauth_token_endpoint_auth_methods_supported.split(",")
client.token_endpoint_auth_method = auth_methods[0]
}
const as = {
issuer: `${authorizationUrl.protocol}//${authorizationUrl.host}`,
authorization_endpoint: connectData.oauth_authorization_endpoint,
token_endpoint: connectData.oauth_token_endpoint,
introspection_endpoint: connectData.oauth_introspection_endpoint,
}
console.log("STARTING--- Oauth.validateAuthResponse")
const params = Oauth.validateAuthResponse(as, client, new URLSearchParams(claimData as any), state)
if (Oauth.isOAuth2Error(params)) {
console.log('error', params)
throw new Error() // Handle OAuth 2.0 redirect error
}
console.log("ENDING--- Oauth.validateAuthResponse")
console.log("STARTING--- Oauth.authorizationCodeGrantRequest")
const response = await Oauth.authorizationCodeGrantRequest(
as,
client,
params,
connectData.redirect_uri,
codeVerifier,
)
const payload = await response.json()
console.log("ENDING--- Oauth.authorizationCodeGrantRequest", payload)
//If payload.patient is not set, make sure we extract the patient ID from the id_token or make an introspection req //If payload.patient is not set, make sure we extract the patient ID from the id_token or make an introspection req
if(!payload.patient){ if(!payload.patient && payload.id_token){
// //
console.log("NO PATIENT ID present, decoding jwt to extract patient") console.log("NO PATIENT ID present, decoding jwt to extract patient")
//const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token) //const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token)
@ -145,6 +127,8 @@ export class MedicalSourcesComponent implements OnInit {
payload.patient = jwtDecode(payload.id_token, new BrowserAdapter())["profile"].replace(/^(Patient\/)/,'') payload.patient = jwtDecode(payload.id_token, new BrowserAdapter())["profile"].replace(/^(Patient\/)/,'')
} }
//Create FHIR Client //Create FHIR Client
const sourceCredential: Source = { const sourceCredential: Source = {
@ -167,7 +151,7 @@ export class MedicalSourcesComponent implements OnInit {
// @ts-ignore - in some cases the getAccessTokenExpiration is a string, which cases failures to store Source in db. // @ts-ignore - in some cases the getAccessTokenExpiration is a string, which cases failures to store Source in db.
expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())), expires_at: parseInt(getAccessTokenExpiration(payload, new BrowserAdapter())),
confidential: connectData.confidential
} }
await this.fastenApi.createSource(sourceCredential).subscribe( await this.fastenApi.createSource(sourceCredential).subscribe(
@ -267,4 +251,45 @@ export class MedicalSourcesComponent implements OnInit {
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
); );
} }
private async swapOauthPKCEToken(state: string, codeVerifier: any, authorizationUrl: URL, connectData: LighthouseSource, claimData: AuthorizeClaim){
// @ts-expect-error
const client: oauth.Client = {
client_id: connectData.client_id,
token_endpoint_auth_method: 'none',
}
//check if the oauth_token_endpoint_auth_methods_supported field is set
if(connectData.oauth_token_endpoint_auth_methods_supported){
let auth_methods = connectData.oauth_token_endpoint_auth_methods_supported.split(",")
client.token_endpoint_auth_method = auth_methods[0]
}
const as = {
issuer: `${authorizationUrl.protocol}//${authorizationUrl.host}`,
authorization_endpoint: connectData.oauth_authorization_endpoint,
token_endpoint: connectData.oauth_token_endpoint,
introspection_endpoint: connectData.oauth_introspection_endpoint,
}
console.log("STARTING--- Oauth.validateAuthResponse")
const params = Oauth.validateAuthResponse(as, client, new URLSearchParams(claimData as any), state)
if (Oauth.isOAuth2Error(params)) {
console.log('error', params)
throw new Error() // Handle OAuth 2.0 redirect error
}
console.log("ENDING--- Oauth.validateAuthResponse")
console.log("STARTING--- Oauth.authorizationCodeGrantRequest")
const response = await Oauth.authorizationCodeGrantRequest(
as,
client,
params,
connectData.redirect_uri,
codeVerifier,
)
let payload = await response.json()
console.log("ENDING--- Oauth.authorizationCodeGrantRequest", payload)
return payload
}
} }

View File

@ -42,6 +42,23 @@ export class LighthouseService {
return authorizationUrl return authorizationUrl
} }
generateConfidentialSourceAuthorizeUrl(state: string, lighthouseSource: LighthouseSource): URL {
// generate the authorization url
const authorizationUrl = new URL(lighthouseSource.oauth_authorization_endpoint);
authorizationUrl.searchParams.set('client_id', lighthouseSource.client_id);
authorizationUrl.searchParams.set('redirect_uri', lighthouseSource.redirect_uri);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('state', state);
if(lighthouseSource.scopes && lighthouseSource.scopes.length){
authorizationUrl.searchParams.set('scope', lighthouseSource.scopes.join(' '));
}
if (lighthouseSource.aud) {
authorizationUrl.searchParams.set('aud', lighthouseSource.aud);
}
return authorizationUrl
}
getSourceAuthorizeClaim(sourceType: string, state: string): Observable<AuthorizeClaim> { getSourceAuthorizeClaim(sourceType: string, state: string): Observable<AuthorizeClaim> {
return this._httpClient.get<any>(`${environment.lighthouse_api_endpoint_base}/claim/${sourceType}`, {params: {"state": state}}) return this._httpClient.get<any>(`${environment.lighthouse_api_endpoint_base}/claim/${sourceType}`, {params: {"state": state}})
.pipe( .pipe(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB