Merge pull request #6 from fastenhealth/sandbox

This commit is contained in:
Jason Kulatunga 2022-10-03 22:22:07 -07:00 committed by GitHub
commit 94d26ad075
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 326 additions and 303 deletions

View File

@ -10,7 +10,7 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
omnibus: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -29,10 +29,10 @@ jobs:
# run: | # run: |
# cd /work # cd /work
# make binary-frontend && echo "print contents of /work/dist" && ls -alt /work/dist # make binary-frontend && echo "print contents of /work/dist" && ls -alt /work/dist
- name: Set up QEMU # - name: Set up QEMU
uses: docker/setup-qemu-action@v2 # uses: docker/setup-qemu-action@v2
with: # with:
platforms: 'arm64,arm' # platforms: 'arm64,arm'
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
@ -59,9 +59,9 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
context: . context: .
file: docker/Dockerfile file: Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@ -2,7 +2,9 @@ FROM node:18.9.0 as frontend-build
WORKDIR /usr/src/fastenhealth/frontend WORKDIR /usr/src/fastenhealth/frontend
#COPY frontend/package.json frontend/yarn.lock ./ #COPY frontend/package.json frontend/yarn.lock ./
COPY frontend/package.json ./ COPY frontend/package.json ./
RUN yarn install --frozen-lockfile #COPY frontend/yarn.lock ./
RUN yarn config set registry "http://registry.npmjs.org" \
&& yarn install --frozen-lockfile --network-timeout 100000
COPY frontend/ ./ COPY frontend/ ./
RUN yarn run build -- --configuration sandbox --output-path=../dist RUN yarn run build -- --configuration sandbox --output-path=../dist
@ -19,8 +21,9 @@ RUN CGO_ENABLED=0 go build -o /go/bin/fasten ./backend/cmd/fasten/
# create folder structure # create folder structure
RUN mkdir -p /opt/fasten/db \ RUN mkdir -p /opt/fasten/db \
mkdir -p /opt/fasten/web \ && mkdir -p /opt/fasten/web \
mkdir -p /opt/fasten/config && mkdir -p /opt/fasten/config \
&& curl -o /opt/fasten/db/fasten.db -L https://github.com/fastenhealth/testdata/raw/main/fasten.db

View File

@ -20,6 +20,7 @@ const routes: Routes = [
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ CanActivateAuthGuard] }, { path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ CanActivateAuthGuard] },
{ path: 'source/:source_id/resource/:resource_id', component: ResourceDetailComponent, canActivate: [ CanActivateAuthGuard] }, { path: 'source/:source_id/resource/:resource_id', component: ResourceDetailComponent, canActivate: [ CanActivateAuthGuard] },
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ CanActivateAuthGuard] }, { path: 'sources', component: MedicalSourcesComponent, canActivate: [ CanActivateAuthGuard] },
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ CanActivateAuthGuard] },

View File

@ -22,18 +22,18 @@ export class AppComponent implements OnInit {
navbarBackdrop.classList.add('az-navbar-backdrop'); navbarBackdrop.classList.add('az-navbar-backdrop');
document.querySelector('body').appendChild(navbarBackdrop); document.querySelector('body').appendChild(navbarBackdrop);
//TODO: onfirst load the header is always shown, why?
// seems to be related to the presence of jwt token, and/or auth-interceptor.
//determine if we should show the header //determine if we should show the header
this.router.events.subscribe(event => this.modifyHeader(event)); this.router.events.subscribe(event => this.modifyHeader(event));
} }
modifyHeader(event) { modifyHeader(event) {
if(event instanceof NavigationEnd && event.url?.startsWith('/auth')) if (event instanceof NavigationEnd) {
{ if (event.url?.startsWith('/auth')) {
this.showHeader = false; this.showHeader = false;
} else { } else {
this.showHeader = true; // console.log("NU")
this.showHeader = true;
}
} }
} }
} }

View File

@ -2,24 +2,24 @@
<div class="component-item"> <div class="component-item">
<label>Connected</label> <label>Connected</label>
<nav class="nav flex-column"> <nav class="nav flex-column">
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Active Sources</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Active Sources</a>
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Manual</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Manual</a>
</nav> </nav>
<label>Categories</label> <label>Categories</label>
<nav class="nav flex-column"> <nav class="nav flex-column">
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Hospital Networks/Clinics</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Hospital Networks/Clinics</a>
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Pharmacies</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Pharmacies</a>
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Insurers</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Insurers</a>
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Laboratoreies</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Laboratories</a>
<a routerLink="/form/form-elements" routerLinkActive="active" class="nav-link">Wearables</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Wearables</a>
</nav> </nav>
<label>Industry</label> <label>Industry</label>
<nav class="nav flex-column"> <nav class="nav flex-column">
<a routerLink="/charts/chartjs" routerLinkActive="active" class="nav-link">Medical</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Medical</a>
<a routerLink="/charts/chartjs" routerLinkActive="active" class="nav-link">Dental</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Dental</a>
<a routerLink="/charts/chartjs" routerLinkActive="active" class="nav-link">Vision</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Vision</a>
<a routerLink="/charts/chartjs" routerLinkActive="active" class="nav-link">Mental</a> <a ngbTooltip="not yet implemented" routerLinkActive="active" class="nav-link">Mental</a>
</nav> </nav>
</div><!-- component-item --> </div><!-- component-item -->

View File

@ -19,7 +19,7 @@
</ul> </ul>
</div><!-- az-header-menu --> </div><!-- az-header-menu -->
<div class="az-header-right"> <div class="az-header-right">
<a routerLink="/" class="az-header-search-link"><i class="fas fa-search"></i></a> <a ngbTooltip="not yet implemented" class="az-header-search-link"><i class="fas fa-search"></i></a>
<div class="az-header-message"> <div class="az-header-message">
<a routerLink="/"><i class="typcn typcn-messages"></i></a> <a routerLink="/"><i class="typcn typcn-messages"></i></a>
</div><!-- az-header-message --> </div><!-- az-header-message -->
@ -33,35 +33,22 @@
<p class="az-notification-text">You have 2 unread notification</p> <p class="az-notification-text">You have 2 unread notification</p>
<div class="az-notification-list"> <div class="az-notification-list">
<div class="media new"> <div class="media new">
<div class="az-img-user"><img src="assets/images/img2.jpg" alt=""></div> <div class="az-img-user"><img src="assets/sources/aetna.png" alt=""></div>
<div class="media-body"> <div class="media-body">
<p>Congratulate <strong>Socrates Itumay</strong> for work anniversaries</p> <p><strong>AETNA</strong> added 3 new records</p>
<span>Mar 15 12:32pm</span> <span>Mar 15 12:32pm</span>
</div><!-- media-body --> </div><!-- media-body -->
</div><!-- media --> </div><!-- media -->
<div class="media new"> <div class="media new">
<div class="az-img-user online"><img src="assets/images/img3.jpg" alt=""></div> <div class="az-img-user online"><img src="assets/sources/cigna.png" alt=""></div>
<div class="media-body"> <div class="media-body">
<p><strong>Joyce Chua</strong> just created a new blog post</p> <p><strong>CIGNA</strong> added 34 new records</p>
<span>Mar 13 04:16am</span> <span>Mar 13 04:16am</span>
</div><!-- media-body --> </div><!-- media-body -->
</div><!-- media --> </div><!-- media -->
<div class="media">
<div class="az-img-user"><img src="assets/images/img4.jpg" alt=""></div>
<div class="media-body">
<p><strong>Althea Cabardo</strong> just created a new blog post</p>
<span>Mar 13 02:56am</span>
</div><!-- media-body -->
</div><!-- media -->
<div class="media">
<div class="az-img-user"><img src="assets/images/img5.jpg" alt=""></div>
<div class="media-body">
<p><strong>Adrian Monino</strong> added new comment on your photo</p>
<span>Mar 12 10:40pm</span>
</div><!-- media-body -->
</div><!-- media -->
</div><!-- az-notification-list --> </div><!-- az-notification-list -->
<div class="dropdown-footer"><a href="">View All Notifications</a></div> <div class="dropdown-footer"><a ngbTooltip="not yet implemented">View All Notifications</a></div>
</div><!-- dropdown-menu --> </div><!-- dropdown-menu -->
</div><!-- az-header-notification --> </div><!-- az-header-notification -->
<div class="dropdown az-profile-menu" ngbDropdown> <div class="dropdown az-profile-menu" ngbDropdown>
@ -78,7 +65,7 @@
<span>Adminstrator</span> <span>Adminstrator</span>
</div><!-- az-header-profile --> </div><!-- az-header-profile -->
<a routerLink="/general-pages/profile" class="dropdown-item"><i class="typcn typcn-time"></i> Activity Logs</a> <a ngbTooltip="not yet implemented" class="dropdown-item"><i class="typcn typcn-time"></i> Activity Logs</a>
<a (click)="signOut($event)" class="dropdown-item"><i class="typcn typcn-power-outline"></i> Sign Out</a> <a (click)="signOut($event)" class="dropdown-item"><i class="typcn typcn-power-outline"></i> Sign Out</a>
</div><!-- dropdown-menu --> </div><!-- dropdown-menu -->
</div> </div>

View File

@ -31,12 +31,14 @@ import {ListDeviceComponent} from './list-generic-resource/list-device.component
import {ListDiagnosticReportComponent} from './list-generic-resource/list-diagnostic-report.component'; import {ListDiagnosticReportComponent} from './list-generic-resource/list-diagnostic-report.component';
import {ListGoalComponent} from './list-generic-resource/list-goal.component'; import {ListGoalComponent} from './list-generic-resource/list-goal.component';
import { ListFallbackResourceComponent } from './list-fallback-resource/list-fallback-resource.component'; import { ListFallbackResourceComponent } from './list-fallback-resource/list-fallback-resource.component';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule, RouterModule,
BrowserModule, BrowserModule,
NgxDatatableModule, NgxDatatableModule,
NgbModule,
], ],
declarations: [ declarations: [
ComponentsSidebarComponent, ComponentsSidebarComponent,

View File

@ -0,0 +1,19 @@
export class LighthouseSourceMetadata {
authorization_endpoint: string
token_endpoint: string
introspection_endpoint: string
userinfo_endpoint: string
scopes_supported: string[]
issuer: string
grant_types_supported: string[]
response_types_supported: string[]
aud: string
code_challenge_methods_supported: string[]
api_endpoint_base_url: string
client_id: string
redirect_uri: string
confidential: boolean
}

View File

@ -1,17 +0,0 @@
export class LighthouseSource {
oauth_authorization_endpoint: string
oauth_token_endpoint: string
oauth_registration_endpoint: string
oauth_introspection_endpoint: string
oauth_userinfo_endpoint: string
oauth_token_endpoint_auth_methods_supported: string
api_endpoint_base_url: string
response_type: string
client_id: string
scopes: string[]
redirect_uri: string
aud: string
confidential: boolean
}

View File

@ -1,7 +0,0 @@
import { LighthouseSource } from './lighthouse-source';
describe('LighthouseSource', () => {
it('should create an instance', () => {
expect(new LighthouseSource()).toBeTruthy();
});
});

View File

@ -37,10 +37,13 @@
</div><!-- form-group --> </div><!-- form-group -->
<button [disabled]="!userForm.form.valid" type="submit" class="btn btn-az-primary btn-block">Sign In</button> <button [disabled]="!userForm.form.valid" type="submit" class="btn btn-az-primary btn-block">Sign In</button>
<div *ngIf="errorMsg" class="alert alert-danger mt-3" role="alert">
<strong>Error</strong> {{errorMsg}}
</div>
</form> </form>
</div><!-- az-signin-header --> </div><!-- az-signin-header -->
<div class="az-signin-footer"> <div class="az-signin-footer">
<p><a href="">Forgot password?</a></p> <p><a ngbTooltip="not yet implemented">Forgot password?</a></p>
<p>Don't have an account? <a routerLink="/auth/signup">Create an Account</a></p> <p>Don't have an account? <a routerLink="/auth/signup">Create an Account</a></p>
</div><!-- az-signin-footer --> </div><!-- az-signin-footer -->
</div><!-- az-card-signin --> </div><!-- az-card-signin -->

View File

@ -11,6 +11,7 @@ import {Router} from '@angular/router';
export class AuthSigninComponent implements OnInit { export class AuthSigninComponent implements OnInit {
submitted: boolean = false submitted: boolean = false
existingUser: User = new User() existingUser: User = new User()
errorMsg: string = ""
constructor(private fastenApi: FastenApiService, private router: Router) { } constructor(private fastenApi: FastenApiService, private router: Router) { }
@ -22,8 +23,9 @@ export class AuthSigninComponent implements OnInit {
this.fastenApi.signin(this.existingUser.username, this.existingUser.password).subscribe((tokenResp: any) => { this.fastenApi.signin(this.existingUser.username, this.existingUser.password).subscribe((tokenResp: any) => {
console.log(tokenResp); console.log(tokenResp);
this.router.navigateByUrl('/dashboard'); this.router.navigateByUrl('/dashboard');
}, (err)=>{
this.errorMsg = err?.error?.error || "an unknown error occurred during sign-in"
}) })
} }
} }

View File

@ -64,6 +64,10 @@
</div> </div>
</div><!-- form-group --> </div><!-- form-group -->
<button [disabled]="!userForm.form.valid" type="submit" class="btn btn-az-primary btn-block">Create Account</button> <button [disabled]="!userForm.form.valid" type="submit" class="btn btn-az-primary btn-block">Create Account</button>
<div *ngIf="errorMsg" class="alert alert-danger mt-3" role="alert">
<strong>Error</strong> {{errorMsg}}
</div>
</form> </form>
</div><!-- az-signup-header --> </div><!-- az-signup-header -->
<div class="az-signup-footer"> <div class="az-signup-footer">

View File

@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import {FastenApiService} from '../../services/fasten-api.service'; import {FastenApiService} from '../../services/fasten-api.service';
import {LighthouseSource} from '../../models/lighthouse/lighthouse-source';
import {User} from '../../models/fasten/user'; import {User} from '../../models/fasten/user';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
@ -12,6 +11,7 @@ import {Router} from '@angular/router';
export class AuthSignupComponent implements OnInit { export class AuthSignupComponent implements OnInit {
submitted: boolean = false submitted: boolean = false
newUser: User = new User() newUser: User = new User()
errorMsg: string = ""
constructor(private fastenApi: FastenApiService, private router: Router) { } constructor(private fastenApi: FastenApiService, private router: Router) { }
@ -25,7 +25,10 @@ export class AuthSignupComponent implements OnInit {
console.log(tokenResp); console.log(tokenResp);
this.router.navigateByUrl('/dashboard'); this.router.navigateByUrl('/dashboard');
}) },
(err)=>{
this.errorMsg = err?.error?.error || "an unknown error occurred during sign-up"
})
} }
} }

View File

@ -29,10 +29,10 @@
</nav> </nav>
<nav class="nav"> <nav class="nav">
<a class="nav-link" routerLink="/"><i class="far fa-save"></i> Save Report</a> <a class="nav-link" routerLink="/" ngbTooltip="not yet implemented"><i class="far fa-save"></i> Save Report</a>
<a class="nav-link" routerLink="/"><i class="far fa-file-pdf"></i> Export to PDF</a> <a class="nav-link" routerLink="/" ngbTooltip="not yet implemented"><i class="far fa-file-pdf"></i> Export to PDF</a>
<a class="nav-link" routerLink="/"><i class="far fa-envelope"></i>Send to Email</a> <a class="nav-link" routerLink="/" ngbTooltip="not yet implemented"><i class="far fa-envelope"></i>Send to Email</a>
<a class="nav-link" routerLink="/"><i class="fas fa-ellipsis-h"></i></a> <a class="nav-link" routerLink="/" ngbTooltip="not yet implemented"><i class="fas fa-ellipsis-h"></i></a>
</nav> </nav>
</div> </div>
@ -107,8 +107,8 @@
<div class="media"> <div class="media">
<div ><fa-icon [icon]="['fas', 'calendar']"></fa-icon></div> <div ><fa-icon [icon]="['fas', 'calendar']"></fa-icon></div>
<div class="media-body"><label >Start Date</label> <div class="media-body"><label >Start Date</label>
<div class="date"><span >Sept 01, 2018</span><a <div class="date"><span >Sept 01, 2018</span>
href="#"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a> <a ngbTooltip="not yet implemented"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a>
</div> </div>
</div> </div>
</div> </div>
@ -117,8 +117,7 @@
<div class="media"> <div class="media">
<div ><fa-icon [icon]="['fas', 'calendar']"></fa-icon></div> <div ><fa-icon [icon]="['fas', 'calendar']"></fa-icon></div>
<div class="media-body"><label >End Date</label> <div class="media-body"><label >End Date</label>
<div class="date"><span >Sept 30, 2018</span><a <div class="date"><span >Sept 30, 2018</span><a ngbTooltip="not yet implemented"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a>
href="#"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a>
</div> </div>
</div> </div>
</div> </div>
@ -127,8 +126,7 @@
<div class="media"> <div class="media">
<div ><fa-icon [icon]="['fas', 'hospital']"></fa-icon></div> <div ><fa-icon [icon]="['fas', 'hospital']"></fa-icon></div>
<div class="media-body"><label >Source Type</label> <div class="media-body"><label >Source Type</label>
<div class="date"><span >All</span><a <div class="date"><span >All</span><a ngbTooltip="not yet implemented"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a>
href="#"><fa-icon [icon]="['fas', 'caret-down']"></fa-icon></a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import {FastenApiService} from '../../services/fasten-api.service'; import {FastenApiService} from '../../services/fasten-api.service';
import {LighthouseSource} from '../../models/lighthouse/lighthouse-source';
import {Source} from '../../models/fasten/source'; import {Source} from '../../models/fasten/source';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {Summary} from '../../models/fasten/summary'; import {Summary} from '../../models/fasten/summary';

View File

@ -1,20 +1,22 @@
import {Component, HostListener, OnInit} from '@angular/core'; import {Component, HostListener, OnInit} from '@angular/core';
import {LighthouseService} from '../../services/lighthouse.service'; import {LighthouseService} from '../../services/lighthouse.service';
import {FastenApiService} from '../../services/fasten-api.service'; import {FastenApiService} from '../../services/fasten-api.service';
import {LighthouseSource} from '../../models/lighthouse/lighthouse-source'; import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-source-metadata';
import * as Oauth from '@panva/oauth4webapi'; import * as Oauth from '@panva/oauth4webapi';
import {AuthorizeClaim} from '../../models/lighthouse/authorize-claim'; import {AuthorizeClaim} from '../../models/lighthouse/authorize-claim';
import {Source} from '../../models/fasten/source'; import {Source} from '../../models/fasten/source';
import {getAccessTokenExpiration, jwtDecode} from 'fhirclient/lib/lib'; import {getAccessTokenExpiration, jwtDecode} from 'fhirclient/lib/lib';
import BrowserAdapter from 'fhirclient/lib/adapters/BrowserAdapter'; import BrowserAdapter from 'fhirclient/lib/adapters/BrowserAdapter';
import {Observable, of, throwError} from 'rxjs'; import {Observable, of, throwError, fromEvent } from 'rxjs';
import {concatMap, delay, retryWhen} from 'rxjs/operators'; import {concatMap, delay, retryWhen, timeout, first, map, filter, catchError} from 'rxjs/operators';
import * as FHIR from "fhirclient" import * as FHIR from "fhirclient"
import {MetadataSource} from '../../models/fasten/metadata-source'; import {MetadataSource} from '../../models/fasten/metadata-source';
import {NgbModal, ModalDismissReasons} from '@ng-bootstrap/ng-bootstrap'; import {NgbModal, ModalDismissReasons} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {Location} from '@angular/common';
// If you dont import this angular will import the wrong "Location"
export const retryCount = 24; //wait 2 minutes (5 * 24 = 120) export const sourceConnectWindowTimeout = 24*5000 //wait 2 minutes (5 * 24 = 120)
export const retryWaitMilliSeconds = 5000; //wait 5 seconds
@Component({ @Component({
selector: 'app-medical-sources', selector: 'app-medical-sources',
@ -27,6 +29,9 @@ export class MedicalSourcesComponent implements OnInit {
private lighthouseApi: LighthouseService, private lighthouseApi: LighthouseService,
private fastenApi: FastenApiService, private fastenApi: FastenApiService,
private modalService: NgbModal, private modalService: NgbModal,
private route: ActivatedRoute,
private router: Router,
private location: Location,
) { } ) { }
status: { [name: string]: string } = {} status: { [name: string]: string } = {}
@ -44,6 +49,11 @@ export class MedicalSourcesComponent implements OnInit {
this.fastenApi.getMetadataSources().subscribe((metadataSources: {[name:string]: MetadataSource}) => { this.fastenApi.getMetadataSources().subscribe((metadataSources: {[name:string]: MetadataSource}) => {
this.metadataSources = metadataSources this.metadataSources = metadataSources
const callbackSourceType = this.route.snapshot.paramMap.get('source_type')
if(callbackSourceType){
this.callback(callbackSourceType).then(console.log)
}
this.fastenApi.getSources() this.fastenApi.getSources()
.subscribe((sourceList: Source[]) => { .subscribe((sourceList: Source[]) => {
@ -74,123 +84,113 @@ export class MedicalSourcesComponent implements OnInit {
this.status[sourceType] = "authorize" this.status[sourceType] = "authorize"
this.lighthouseApi.getLighthouseSource(sourceType) this.lighthouseApi.getLighthouseSource(sourceType)
.subscribe(async (connectData: LighthouseSource) => { .subscribe(async (sourceMetadata: LighthouseSourceMetadata) => {
console.log(connectData); console.log(sourceMetadata);
let authorizationUrl = await this.lighthouseApi.generateSourceAuthorizeUrl(sourceType, sourceMetadata)
const state = this.uuidV4()
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 // redirect to lighthouse with uri's
window.open(authorizationUrl.toString(), "_blank"); this.lighthouseApi.redirectWithOriginAndDestination(authorizationUrl.toString(), sourceType)
//wait for response
this.waitForClaimOrTimeout(sourceType, state).subscribe(async (claimData: AuthorizeClaim) => {
console.log("claim response:", claimData)
this.status[sourceType] = "token"
let payload: any
if(connectData.confidential){
// we should have an access_token (and optionally a refresh_token) in the claim
payload = claimData
//patient may be returned as patient_id
payload.patient = payload.patient ? payload.patient : payload.patient_id
} else {
payload = await this.swapOauthPKCEToken(state, codeVerifier, authorizationUrl, connectData, claimData)
}
//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 && payload.id_token){
//
console.log("NO PATIENT ID present, decoding jwt to extract patient")
//const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token)
//console.log(introspectionResp)
payload.patient = jwtDecode(payload.id_token, new BrowserAdapter())["profile"].replace(/^(Patient\/)/,'')
}
//Create FHIR Client
const sourceCredential: Source = {
source_type: sourceType,
oauth_authorization_endpoint: connectData.oauth_authorization_endpoint,
oauth_token_endpoint: connectData.oauth_token_endpoint,
oauth_registration_endpoint: connectData.oauth_registration_endpoint,
oauth_introspection_endpoint: connectData.oauth_introspection_endpoint,
oauth_userinfo_endpoint: connectData.oauth_userinfo_endpoint,
oauth_token_endpoint_auth_methods_supported: connectData.oauth_token_endpoint_auth_methods_supported,
api_endpoint_base_url: connectData.api_endpoint_base_url,
client_id: connectData.client_id,
redirect_uri: connectData.redirect_uri,
scopes: connectData.scopes ? connectData.scopes.join(' ') : undefined,
patient_id: payload.patient,
access_token: payload.access_token,
refresh_token: payload.refresh_token,
id_token: payload.id_token,
code_challenge: codeChallenge,
code_verifier: codeVerifier,
// @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())),
confidential: connectData.confidential
}
await this.fastenApi.createSource(sourceCredential).subscribe(
(respData) => {
delete this.status[sourceType]
window.location.reload();
console.log("source credential create response:", respData)
},
(err) => {
delete this.status[sourceType]
window.location.reload();
console.log(err)
}
)
})
}); });
} }
@HostListener('window:message', ['$event']) async callback(sourceType: string) {
onPostMessage(event: MessageEvent) {
console.log("received a message from OAuth popup - "+ event.data, "sleeping 5 seconds") //get the source metadata again
// todo, process event, (retrieve code from passport api and swap for code) await this.lighthouseApi.getLighthouseSource(sourceType)
setTimeout(() => { .subscribe(async (sourceMetadata: LighthouseSourceMetadata) => {
console.log("responding to OAuth popup...")
event.source.postMessage(JSON.stringify({close:true}), //get required parameters from the URI and local storage
// @ts-ignore const callbackUrlParts = new URL(window.location.href)
event.origin); const fragmentParams = new URLSearchParams(callbackUrlParts.hash.substring(1))
}, 5000); const callbackCode = callbackUrlParts.searchParams.get("code") || fragmentParams.get("code")
const callbackState = callbackUrlParts.searchParams.get("state") || fragmentParams.get("state")
const callbackError = callbackUrlParts.searchParams.get("error") || fragmentParams.get("error")
const callbackErrorDescription = callbackUrlParts.searchParams.get("error_description") || fragmentParams.get("error_description")
//reset the url, removing the params and fragment from the current url.
const urlTree = this.router.createUrlTree(["/sources"],{
relativeTo: this.route,
});
this.location.replaceState(urlTree.toString());
const expectedState = localStorage.getItem(`${sourceType}:state`)
localStorage.removeItem(`${sourceType}:state`)
if(callbackError && !callbackCode){
//TOOD: print this message in the UI
console.error("an error occurred while authenticating to this source. Please try again later", callbackErrorDescription)
return
}
console.log("callback code:", callbackCode)
this.status[sourceType] = "token"
let payload: any
payload = await this.lighthouseApi.swapOauthToken(sourceType, sourceMetadata,expectedState, callbackState, callbackCode)
//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 && payload.id_token){
//
console.log("NO PATIENT ID present, decoding jwt to extract patient")
//const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token)
//console.log(introspectionResp)
payload.patient = jwtDecode(payload.id_token, new BrowserAdapter())["profile"].replace(/^(Patient\/)/,'')
}
//Create FHIR Client
const sourceCredential: Source = {
source_type: sourceType,
oauth_authorization_endpoint: sourceMetadata.authorization_endpoint,
oauth_token_endpoint: sourceMetadata.token_endpoint,
oauth_registration_endpoint: "",
oauth_introspection_endpoint: sourceMetadata.introspection_endpoint,
oauth_userinfo_endpoint: sourceMetadata.userinfo_endpoint,
oauth_token_endpoint_auth_methods_supported: "",
api_endpoint_base_url: sourceMetadata.api_endpoint_base_url,
client_id: sourceMetadata.client_id,
redirect_uri: sourceMetadata.redirect_uri,
scopes: sourceMetadata.scopes_supported ? sourceMetadata.scopes_supported.join(' ') : undefined,
patient_id: payload.patient,
access_token: payload.access_token,
refresh_token: payload.refresh_token,
id_token: payload.id_token,
code_challenge: "",
code_verifier: "",
// @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())),
confidential: sourceMetadata.confidential
}
await this.fastenApi.createSource(sourceCredential).subscribe(
(respData) => {
delete this.status[sourceType]
// window.location.reload();
console.log("source credential create response:", respData)
//remove item from available sources list, add to connected sources.
this.availableSourceList.splice(this.availableSourceList.indexOf(this.metadataSources[sourceType]), 1);
this.connectedSourceList.push({source: respData, metadata: this.metadataSources[sourceType]})
},
(err) => {
delete this.status[sourceType]
// window.location.reload();
console.log(err)
}
)
})
} }
uploadSourceBundle(event) { uploadSourceBundle(event) {
this.uploadedFile = [event.addedFiles[0]] this.uploadedFile = [event.addedFiles[0]]
this.fastenApi.createManualSource(event.addedFiles[0]).subscribe( this.fastenApi.createManualSource(event.addedFiles[0]).subscribe(
@ -226,7 +226,6 @@ export class MedicalSourcesComponent implements OnInit {
}, },
(err) => { (err) => {
delete this.status[source.source_type] delete this.status[source.source_type]
console.log(err) console.log(err)
} }
) )
@ -242,67 +241,4 @@ export class MedicalSourcesComponent implements OnInit {
} }
} }
private waitForClaimOrTimeout(sourceType: string, state: string): Observable<AuthorizeClaim> {
return this.lighthouseApi.getSourceAuthorizeClaim(sourceType, state).pipe(
retryWhen(error =>
error.pipe(
concatMap((error, count) => {
if (count <= retryCount && error.status == 500) {
return of(error);
}
return throwError(error);
}),
delay(retryWaitMilliSeconds)
)
)
)
}
private uuidV4(){
// @ts-ignore
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(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

@ -2,8 +2,6 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import {LighthouseSource} from '../models/lighthouse/lighthouse-source';
import {environment} from '../../environments/environment';
import {map} from 'rxjs/operators'; import {map} from 'rxjs/operators';
import {ResponseWrapper} from '../models/response-wrapper'; import {ResponseWrapper} from '../models/response-wrapper';
import {Source} from '../models/fasten/source'; import {Source} from '../models/fasten/source';
@ -42,8 +40,13 @@ export class FastenApiService {
signup(newUser: User): Observable<any> { signup(newUser: User): Observable<any> {
return this._httpClient.post<any>(`${this.getBasePath()}/api/auth/signup`, newUser).pipe( return this._httpClient.post<any>(`${this.getBasePath()}/api/auth/signup`, newUser).pipe(
map((res: any) => { map((res: any) => {
localStorage.setItem(this.AUTH_TOKEN_KEY, res.data); if(res.success){
return res.data localStorage.setItem(this.AUTH_TOKEN_KEY, res.data);
return res.data
} else {
throw new Error(res.error)
}
} }
)); ));
} }
@ -58,8 +61,12 @@ export class FastenApiService {
return this._httpClient.post<any>(`${this.getBasePath()}/api/auth/signin`, data).pipe( return this._httpClient.post<any>(`${this.getBasePath()}/api/auth/signin`, data).pipe(
map((res: any) => { map((res: any) => {
localStorage.setItem(this.AUTH_TOKEN_KEY, res.data); if(res.success){
return res.data localStorage.setItem(this.AUTH_TOKEN_KEY, res.data);
return res.data
} else {
throw new Error(res.error)
}
} }
)); ));
} }

View File

@ -4,8 +4,8 @@ import {Observable} from 'rxjs';
import {environment} from '../../environments/environment'; import {environment} from '../../environments/environment';
import {map, tap} from 'rxjs/operators'; import {map, tap} from 'rxjs/operators';
import {ResponseWrapper} from '../models/response-wrapper'; import {ResponseWrapper} from '../models/response-wrapper';
import {LighthouseSource} from '../models/lighthouse/lighthouse-source'; import {LighthouseSourceMetadata} from '../models/lighthouse/lighthouse-source-metadata';
import {AuthorizeClaim} from '../models/lighthouse/authorize-claim'; import * as Oauth from '@panva/oauth4webapi';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -15,57 +15,140 @@ export class LighthouseService {
constructor(private _httpClient: HttpClient) { constructor(private _httpClient: HttpClient) {
} }
getLighthouseSource(sourceType: string): Observable<LighthouseSource> { getLighthouseSource(sourceType: string): Observable<LighthouseSourceMetadata> {
return this._httpClient.get<any>(`${environment.lighthouse_api_endpoint_base}/connect/${sourceType}`) return this._httpClient.get<any>(`${environment.lighthouse_api_endpoint_base}/connect/${sourceType}`)
.pipe( .pipe(
map((response: ResponseWrapper) => { map((response: ResponseWrapper) => {
return response.data as LighthouseSource return response.data as LighthouseSourceMetadata
}) })
); );
} }
generatePKCESourceAuthorizeUrl(codeVerifier: string, codeChallenge: string, codeChallengeMethod: string, state: string, lighthouseSource: LighthouseSource): URL {
async generateSourceAuthorizeUrl(sourceType: string, lighthouseSource: LighthouseSourceMetadata): Promise<URL> {
const state = this.uuidV4()
localStorage.setItem(`${sourceType}:state`, state)
// generate the authorization url // generate the authorization url
const authorizationUrl = new URL(lighthouseSource.oauth_authorization_endpoint); const authorizationUrl = new URL(lighthouseSource.authorization_endpoint);
authorizationUrl.searchParams.set('client_id', lighthouseSource.client_id);
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
authorizationUrl.searchParams.set('redirect_uri', lighthouseSource.redirect_uri); authorizationUrl.searchParams.set('redirect_uri', lighthouseSource.redirect_uri);
authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('response_type', lighthouseSource.response_types_supported[0]);
authorizationUrl.searchParams.set('response_mode', 'fragment');
authorizationUrl.searchParams.set('state', state); authorizationUrl.searchParams.set('state', state);
if(lighthouseSource.scopes && lighthouseSource.scopes.length){ authorizationUrl.searchParams.set('client_id', lighthouseSource.client_id);
authorizationUrl.searchParams.set('scope', lighthouseSource.scopes.join(' ')); if(lighthouseSource.scopes_supported && lighthouseSource.scopes_supported.length){
authorizationUrl.searchParams.set('scope', lighthouseSource.scopes_supported.join(' '));
} }
if (lighthouseSource.aud) { if (lighthouseSource.aud) {
authorizationUrl.searchParams.set('aud', lighthouseSource.aud); authorizationUrl.searchParams.set('aud', lighthouseSource.aud);
} }
//this is for providers that support CORS and PKCE (public client auth)
if(!lighthouseSource.confidential){
// https://github.com/panva/oauth4webapi/blob/8eba19eac408bdec5c1fe8abac2710c50bfadcc3/examples/public.ts
const codeVerifier = Oauth.generateRandomCodeVerifier();
const codeChallenge = await Oauth.calculatePKCECodeChallenge(codeVerifier);
const codeChallengeMethod = lighthouseSource.code_challenge_methods_supported[0]; // 'S256'
localStorage.setItem(`${sourceType}:code_verifier`, codeVerifier)
localStorage.setItem(`${sourceType}:code_challenge`, codeChallenge)
localStorage.setItem(`${sourceType}:code_challenge_method`, codeChallengeMethod)
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
}
return authorizationUrl return authorizationUrl
} }
generateConfidentialSourceAuthorizeUrl(state: string, lighthouseSource: LighthouseSource): URL { redirectWithOriginAndDestination(destUrl: string, sourceType: string){
// generate the authorization url const originUrlParts = new URL(window.location.href)
const authorizationUrl = new URL(lighthouseSource.oauth_authorization_endpoint); originUrlParts.hash = "" //reset hash in-case its present.
authorizationUrl.searchParams.set('client_id', lighthouseSource.client_id); originUrlParts.pathname = this.pathJoin([originUrlParts.pathname, `callback/${sourceType}`])
authorizationUrl.searchParams.set('redirect_uri', lighthouseSource.redirect_uri);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('state', state); const redirectUrlParts = new URL(`${environment.lighthouse_api_endpoint_base}/redirect/${sourceType}`);
if(lighthouseSource.scopes && lighthouseSource.scopes.length){ const redirectParams = new URLSearchParams()
authorizationUrl.searchParams.set('scope', lighthouseSource.scopes.join(' ')); redirectParams.set("origin_url", originUrlParts.toString())
redirectParams.set("dest_url", destUrl)
redirectUrlParts.search = redirectParams.toString()
console.log(redirectUrlParts.toString());
// Simulate a mouse click:
window.location.href = redirectUrlParts.toString();
}
async swapOauthToken(sourceType: string, sourceMetadata: LighthouseSourceMetadata, expectedState: string, state: string, code: string){
// @ts-expect-error
const client: oauth.Client = {
client_id: sourceMetadata.client_id
} }
if (lighthouseSource.aud) { //this is for providers that support CORS & PKCE (public client auth)
authorizationUrl.searchParams.set('aud', lighthouseSource.aud); let codeVerifier = undefined
if(!sourceMetadata.confidential){
client.token_endpoint_auth_method = 'none'
codeVerifier = localStorage.getItem(`${sourceType}:code_verifier`)
localStorage.removeItem(`${sourceType}:code_verifier`)
localStorage.removeItem(`${sourceType}:code_challenge`)
localStorage.removeItem(`${sourceType}:code_challenge_method`)
} else {
console.log("This is a confidential client, using lighthouse token endpoint.")
//if this is a confidential client, we need to "override" token endpoint, and use the Fasten Lighthouse to complete the swap
sourceMetadata.token_endpoint = sourceMetadata.redirect_uri.replace("callback", "token")
//use a placeholder client_secret (the actual secret is stored in Lighthouse)
client.client_secret = "placeholder"
client.token_endpoint_auth_method = "client_secret_basic"
codeVerifier = "placeholder"
} }
return authorizationUrl
const as = {
issuer: sourceMetadata.issuer,
authorization_endpoint: sourceMetadata.authorization_endpoint,
token_endpoint: sourceMetadata.token_endpoint,
introspection_endpoint: sourceMetadata.introspection_endpoint,
}
console.log("STARTING--- Oauth.validateAuthResponse")
const params = Oauth.validateAuthResponse(as, client, new URLSearchParams({"code": code, "state": state}), expectedState)
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,
sourceMetadata.redirect_uri,
codeVerifier,
)
let payload = await response.json()
console.log("ENDING--- Oauth.authorizationCodeGrantRequest", payload)
return payload
} }
getSourceAuthorizeClaim(sourceType: string, state: string): Observable<AuthorizeClaim> {
return this._httpClient.get<any>(`${environment.lighthouse_api_endpoint_base}/claim/${sourceType}`, {params: {"state": state}}) private pathJoin(parts: string[], sep?: string): string{
.pipe( const separator = sep || '/';
map((response: ResponseWrapper) => { parts = parts.map((part, index)=>{
return response.data as AuthorizeClaim if (index) {
}) part = part.replace(new RegExp('^' + separator), '');
); }
if (index !== parts.length - 1) {
part = part.replace(new RegExp(separator + '$'), '');
}
return part;
})
return parts.join(separator);
} }
private uuidV4(){
// @ts-ignore
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB