From a6edb24aa0cef1a8d8aea36822377d42ab16c1e8 Mon Sep 17 00:00:00 2001 From: Jason Kulatunga Date: Wed, 11 Oct 2023 20:43:27 -0700 Subject: [PATCH] move registration code into the sourceCredential. Added ability to Reconnect/Update source make sure re-connect function is disabled for manual sources. --- backend/pkg/models/source_credential.go | 84 +++++++++++++ backend/pkg/web/handler/source.go | 110 ++++-------------- backend/pkg/web/handler/unsafe.go | 10 -- backend/pkg/web/server.go | 2 +- .../medical-sources-connected.component.html | 5 +- .../medical-sources-connected.component.ts | 27 ++++- .../src/app/models/fasten/source-state.ts | 2 + .../medical-sources.component.ts | 25 +--- .../src/app/services/lighthouse.service.ts | 38 +++++- 9 files changed, 174 insertions(+), 129 deletions(-) diff --git a/backend/pkg/models/source_credential.go b/backend/pkg/models/source_credential.go index b3411c8f..f8f08bc6 100644 --- a/backend/pkg/models/source_credential.go +++ b/backend/pkg/models/source_credential.go @@ -1,6 +1,7 @@ package models import ( + "bytes" "encoding/json" "fmt" "github.com/fastenhealth/fasten-onprem/backend/pkg/jwk" @@ -134,7 +135,90 @@ func (s *SourceCredential) IsDynamicClient() bool { return len(s.DynamicClientRegistrationMode) > 0 } +// This method will generate a new keypair, register a new dynamic client with the provider +// it will set the following fields: +// - DynamicClientJWKS +// - DynamicClientId +func (s *SourceCredential) RegisterDynamicClient() error { + + //this source requires dynamic client registration + // see https://fhir.epic.com/Documentation?docId=Oauth2§ion=Standalone-Oauth2-OfflineAccess-0 + + // Generate a public-private key pair + // Must be 2048 bits (larger keys will silently fail when used with Epic, untested on other providers) + sourceSpecificClientKeyPair, err := jwk.JWKGenerate() + if err != nil { + return fmt.Errorf("an error occurred while generating device-specific keypair for dynamic client: %w", err) + } + + //store in sourceCredential + serializedKeypair, err := jwk.JWKSerialize(sourceSpecificClientKeyPair) + if err != nil { + return fmt.Errorf("an error occurred while serializing keypair for dynamic client: %w", err) + } + s.DynamicClientJWKS = []map[string]string{ + serializedKeypair, + } + + //generate dynamic client registration request + payload := ClientRegistrationRequest{ + SoftwareId: s.ClientId, + Jwks: ClientRegistrationRequestJwks{ + Keys: []ClientRegistrationRequestJwksKey{ + { + KeyType: "RSA", + KeyId: serializedKeypair["kid"], + Modulus: serializedKeypair["n"], + PublicExponent: serializedKeypair["e"], + }, + }, + }, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("an error occurred while marshalling dynamic client registration request: %w", err) + } + + //http.Post("https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token", "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=%s&scope=system/Patient.read", sourceSpecificClientKeyPair)))) + req, err := http.NewRequest(http.MethodPost, s.RegistrationEndpoint, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("an error occurred while generating dynamic client registration request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) + + registrationResponse, err := http.DefaultClient.Do(req) + + if err != nil { + return fmt.Errorf("an error occurred while sending dynamic client registration request: %w", err) + } + defer registrationResponse.Body.Close() + if registrationResponse.StatusCode >= 300 || registrationResponse.StatusCode < 200 { + b, err := io.ReadAll(registrationResponse.Body) + if err == nil { + log.Printf("Error Response body: %s", string(b)) + } + return fmt.Errorf("an error occurred while reading dynamic client registration response, status code was not 200: %d", registrationResponse.StatusCode) + + } + + //read response + var registrationResponseBytes ClientRegistrationResponse + err = json.NewDecoder(registrationResponse.Body).Decode(®istrationResponseBytes) + if err != nil { + return fmt.Errorf("an error occurred while parsing dynamic client registration response: %w", err) + } + + //store the dynamic client id + s.DynamicClientId = registrationResponseBytes.ClientId + return nil +} + // this will set/update the AccessToken and Expiry using the dynamic client credentials +// it will set the following fields: +// - AccessToken +// - ExpiresAt func (s *SourceCredential) RefreshDynamicClientAccessToken() error { if len(s.DynamicClientRegistrationMode) == 0 { return fmt.Errorf("dynamic client registration mode not set") diff --git a/backend/pkg/web/handler/source.go b/backend/pkg/web/handler/source.go index a67071a1..c1b5cdd3 100644 --- a/backend/pkg/web/handler/source.go +++ b/backend/pkg/web/handler/source.go @@ -1,24 +1,21 @@ package handler import ( - "bytes" - "encoding/json" "fmt" "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" - "github.com/fastenhealth/fasten-onprem/backend/pkg/jwk" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" "github.com/fastenhealth/fasten-sources/clients/factory" sourcePkg "github.com/fastenhealth/fasten-sources/pkg" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/sirupsen/logrus" - "io" "io/ioutil" "net/http" ) -func CreateSource(c *gin.Context) { +func CreateReconnectSource(c *gin.Context) { logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) @@ -39,91 +36,13 @@ func CreateSource(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"success": false}) return } - //this source requires dynamic client registration - // see https://fhir.epic.com/Documentation?docId=Oauth2§ion=Standalone-Oauth2-OfflineAccess-0 - // Generate a public-private key pair - // Must be 2048 bits (larger keys will silently fail when used with Epic, untested on other providers) - sourceSpecificClientKeyPair, err := jwk.JWKGenerate() + err := sourceCred.RegisterDynamicClient() if err != nil { - logger.Errorln("An error occurred while generating device-specific keypair for dynamic client", err) + logger.Errorln("An error occurred while registering dynamic client", err) c.JSON(http.StatusBadRequest, gin.H{"success": false}) return } - //store in sourceCredential - serializedKeypair, err := jwk.JWKSerialize(sourceSpecificClientKeyPair) - if err != nil { - logger.Errorln("An error occurred while serializing keypair for dynamic client", err) - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - sourceCred.DynamicClientJWKS = []map[string]string{ - serializedKeypair, - } - - //generate dynamic client registration request - payload := models.ClientRegistrationRequest{ - SoftwareId: sourceCred.ClientId, - Jwks: models.ClientRegistrationRequestJwks{ - Keys: []models.ClientRegistrationRequestJwksKey{ - { - KeyType: "RSA", - KeyId: serializedKeypair["kid"], - Modulus: serializedKeypair["n"], - PublicExponent: serializedKeypair["e"], - }, - }, - }, - } - payloadBytes, err := json.Marshal(payload) - if err != nil { - logger.Errorln("An error occurred while marshalling dynamic client registration request", err) - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - - //http.Post("https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token", "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=%s&scope=system/Patient.read", sourceSpecificClientKeyPair)))) - req, err := http.NewRequest(http.MethodPost, sourceCred.RegistrationEndpoint, bytes.NewBuffer(payloadBytes)) - if err != nil { - logger.Errorln("An error occurred while generating dynamic client registration request", err) - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sourceCred.AccessToken)) - - registrationResponse, err := http.DefaultClient.Do(req) - - if err != nil { - logger.Errorln("An error occurred while sending dynamic client registration request", err) - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - defer registrationResponse.Body.Close() - if registrationResponse.StatusCode >= 300 || registrationResponse.StatusCode < 200 { - logger.Errorln("An error occurred while reading dynamic client registration response, status code was not 200", registrationResponse.StatusCode) - b, err := io.ReadAll(registrationResponse.Body) - if err == nil { - logger.Printf("Error Response body: %s", string(b)) - } - - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - - //read response - var registrationResponseBytes models.ClientRegistrationResponse - err = json.NewDecoder(registrationResponse.Body).Decode(®istrationResponseBytes) - if err != nil { - logger.Errorln("An error occurred while parsing dynamic client registration response", err) - c.JSON(http.StatusBadRequest, gin.H{"success": false}) - return - } - - //store the dynamic client id - sourceCred.DynamicClientId = registrationResponseBytes.ClientId - //generate a JWT token and then use it to get an access token for the dynamic client err = sourceCred.RefreshDynamicClientAccessToken() if err != nil { @@ -133,11 +52,22 @@ func CreateSource(c *gin.Context) { } } - err := databaseRepo.CreateSource(c, &sourceCred) - if err != nil { - logger.Errorln("An error occurred while storing source credential", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false}) - return + if sourceCred.ID != uuid.Nil { + //reconnect + err := databaseRepo.UpdateSource(c, &sourceCred) + if err != nil { + logger.Errorln("An error occurred while reconnecting source credential", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } + } else { + //create source for the first time + err := databaseRepo.CreateSource(c, &sourceCred) + if err != nil { + logger.Errorln("An error occurred while storing source credential", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } } // after creating the source, we should do a bulk import (in the background) diff --git a/backend/pkg/web/handler/unsafe.go b/backend/pkg/web/handler/unsafe.go index ed3fb987..84db3f64 100644 --- a/backend/pkg/web/handler/unsafe.go +++ b/backend/pkg/web/handler/unsafe.go @@ -53,16 +53,6 @@ func UnsafeRequestSource(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } - //TODO: if source has been updated, we should save the access/refresh token. - //if updatedSource != nil { - // logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource) - // // err := databaseRepo.CreateSource(c, updatedSource) - // // if err != nil { - // // logger.Errorf("An error occurred while updating source credential %v", err) - // // c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) - // // return - // // } - //} var resp map[string]interface{} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index cfd0031a..6f469771 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -64,7 +64,7 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) { { secure.GET("/summary", handler.GetSummary) - secure.POST("/source", handler.CreateSource) + secure.POST("/source", handler.CreateReconnectSource) secure.POST("/source/manual", handler.CreateManualSource) secure.GET("/source", handler.ListSource) secure.GET("/source/:sourceId", handler.GetSource) diff --git a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html index 338157af..5cdca8a9 100644 --- a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html +++ b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.html @@ -43,9 +43,8 @@
- - - + +
diff --git a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts index 39c89eaa..3ba0a0ed 100644 --- a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts +++ b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts @@ -11,6 +11,7 @@ import {ToastService} from '../../services/toast.service'; import {ActivatedRoute, Router} from '@angular/router'; import {Location} from '@angular/common'; import {EventBusService} from '../../services/event-bus.service'; +import {SourceState} from '../../models/fasten/source-state'; @Component({ selector: 'app-medical-sources-connected', @@ -102,7 +103,7 @@ export class MedicalSourcesConnectedComponent implements OnInit { }); this.location.replaceState(urlTree.toString()); - const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState)) + const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState)) as SourceState localStorage.removeItem(callbackState) if(callbackError && !callbackCode){ @@ -147,6 +148,7 @@ export class MedicalSourcesConnectedComponent implements OnInit { //Create FHIR Client const dbSourceCredential = new Source({ + id: expectedSourceStateInfo.reconnect_source_id, source_type: sourceType, authorization_endpoint: sourceMetadata.authorization_endpoint, @@ -364,6 +366,29 @@ export class MedicalSourcesConnectedComponent implements OnInit { }) } + //this is similar to the connectHandler in the MedicalSourcesComponent + public sourceReconnectHandler(selectedSourceListItem: SourceListItem){ + + let sourceType = selectedSourceListItem.metadata.source_type + this.lighthouseApi.getLighthouseSource(sourceType) + .then(async (sourceMetadata: LighthouseSourceMetadata) => { + console.log(sourceMetadata); + let authorizationUrl = await this.lighthouseApi.generateSourceAuthorizeUrl(sourceType, sourceMetadata, selectedSourceListItem.source.id) + + console.log('authorize url:', authorizationUrl.toString()); + // redirect to lighthouse with uri's (or open a new window in desktop mode) + this.lighthouseApi.redirectWithOriginAndDestination(authorizationUrl.toString(), sourceType, sourceMetadata.redirect_uri).subscribe((codeData) => { + //Note: this code will only run in Desktop mode (with popups) + //in non-desktop environments, the user is redirected in the same window, and this code is never executed. + + //always close the modal + this.modalService.dismissAll() + + //redirect the browser back to this page with the code in the query string parameters + this.lighthouseApi.redirectWithDesktopCode(sourceType, codeData) + }) + }); + } private getDismissReason(reason: any): string { if (reason === ModalDismissReasons.ESC) { diff --git a/frontend/src/app/models/fasten/source-state.ts b/frontend/src/app/models/fasten/source-state.ts index 9cf4d282..328edb57 100644 --- a/frontend/src/app/models/fasten/source-state.ts +++ b/frontend/src/app/models/fasten/source-state.ts @@ -2,6 +2,8 @@ export class SourceState { state: string source_type: string //used to override the source_type for sources which have a single redirect url (eg. Epic) + reconnect_source_id?: string //used to reconnect a source + code_verifier?: string code_challenge_method?: string code_challenge?: string diff --git a/frontend/src/app/pages/medical-sources/medical-sources.component.ts b/frontend/src/app/pages/medical-sources/medical-sources.component.ts index f5850894..32189b11 100644 --- a/frontend/src/app/pages/medical-sources/medical-sources.component.ts +++ b/frontend/src/app/pages/medical-sources/medical-sources.component.ts @@ -5,7 +5,7 @@ import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-sourc import {Source} from '../../models/fasten/source'; import {MetadataSource} from '../../models/fasten/metadata-source'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router, UrlSerializer} from '@angular/router'; +import {ActivatedRoute} from '@angular/router'; import {environment} from '../../../environments/environment'; import {BehaviorSubject, forkJoin, Observable, of, Subject} from 'rxjs'; import { @@ -17,7 +17,6 @@ import {debounceTime, distinctUntilChanged, pairwise, startWith} from 'rxjs/oper import {MedicalSourcesFilter, MedicalSourcesFilterService} from '../../services/medical-sources-filter.service'; import {FormControl, FormGroup} from '@angular/forms'; import * as _ from 'lodash'; -import {Location} from '@angular/common'; export const sourceConnectWindowTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120) @@ -79,9 +78,7 @@ export class MedicalSourcesComponent implements OnInit { private activatedRoute: ActivatedRoute, private filterService: MedicalSourcesFilterService, private modalService: NgbModal, - private router: Router, - private urlSerializer: UrlSerializer, - private location: Location, + ) { this.filterService.filterChanges.subscribe((filterInfo) => { @@ -295,22 +292,8 @@ export class MedicalSourcesComponent implements OnInit { //always close the modal this.modalService.dismissAll() - if(!codeData){ - //if we redirected completely, no callback data will be present. - return - } - - //User was shown a popup, which was closed, and data was returned using events - //redirect to callback page with code - - let urlTree = this.router.createUrlTree( - ['/sources/callback/' + sourceType], - { queryParams: codeData, } - ); - - let absUrl = this.location.prepareExternalUrl(this.urlSerializer.serialize(urlTree)) - console.log(absUrl); - window.location.replace(absUrl) + //redirect the browser back to this page with the code in the query string parameters + this.lighthouseApi.redirectWithDesktopCode(sourceType, codeData) }) }); } diff --git a/frontend/src/app/services/lighthouse.service.ts b/frontend/src/app/services/lighthouse.service.ts index 3565a546..3446e4f7 100644 --- a/frontend/src/app/services/lighthouse.service.ts +++ b/frontend/src/app/services/lighthouse.service.ts @@ -13,6 +13,8 @@ import {LighthouseSourceSearch} from '../models/lighthouse/lighthouse-source-sea import {HTTP_CLIENT_TOKEN} from "../dependency-injection"; import {MedicalSourcesFilter} from './medical-sources-filter.service'; import {OpenExternalLink} from '../../lib/utils/external_link'; +import {Router, UrlSerializer} from '@angular/router'; +import {Location} from '@angular/common'; export const sourceConnectDesktopTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120) @@ -21,8 +23,12 @@ export const sourceConnectDesktopTimeout = 24*5000 //wait 2 minutes (5 * 24 = 12 }) export class LighthouseService { - constructor(@Inject(HTTP_CLIENT_TOKEN) private _httpClient: HttpClient) { - } + constructor( + @Inject(HTTP_CLIENT_TOKEN) private _httpClient: HttpClient, + private router: Router, + private urlSerializer: UrlSerializer, + private location: Location, + ) {} public searchLighthouseSources(filter: MedicalSourcesFilter): Observable { if(filter.searchAfter){ @@ -87,11 +93,15 @@ export class LighthouseService { } - async generateSourceAuthorizeUrl(sourceType: string, lighthouseSource: LighthouseSourceMetadata): Promise { + async generateSourceAuthorizeUrl(sourceType: string, lighthouseSource: LighthouseSourceMetadata, reconnectSourceId?: string): Promise { const state = uuidV4() let sourceStateInfo = new SourceState() sourceStateInfo.state = state sourceStateInfo.source_type = sourceType + if(reconnectSourceId){ + //if the source already exists, and we want to re-connect it (because of an expiration), we need to pass the existing source id + sourceStateInfo.reconnect_source_id = reconnectSourceId + } // generate the authorization url const authorizationUrl = new URL(lighthouseSource.authorization_endpoint); @@ -283,6 +293,28 @@ export class LighthouseService { ) } + //after waiting for the desktop code, we need to redirect to the callback page with the code in the query params + // (which is what would have happened if we were in a browser and we were redirected as usual) + redirectWithDesktopCode(sourceType: string, codeData: any){ + + if(!codeData){ + //if we redirected completely, no callback data will be present. + return + } + + //User was shown a popup, which was closed, and data was returned using events + //redirect to callback page with code + + let urlTree = this.router.createUrlTree( + ['/sources/callback/' + sourceType], + { queryParams: codeData, } + ); + + let absUrl = this.location.prepareExternalUrl(this.urlSerializer.serialize(urlTree)) + console.log(absUrl); + window.location.replace(absUrl) + + } }