handle authorize claim for confidential sources.
update BlueButton image to use Medicare logo.
This commit is contained in:
parent
4afbd6f834
commit
5f2f99a202
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -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"}},
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,4 +11,6 @@ export class LighthouseSource {
|
||||||
scopes: string[]
|
scopes: string[]
|
||||||
redirect_uri: string
|
redirect_uri: string
|
||||||
aud: string
|
aud: string
|
||||||
|
|
||||||
|
confidential: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 |
Loading…
Reference in New Issue