307 lines
11 KiB
Go
307 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/fastenhealth/fasten-onprem/backend/pkg/jwk"
|
|
sourcesDefinitions "github.com/fastenhealth/fasten-sources/definitions"
|
|
sourcesPkg "github.com/fastenhealth/fasten-sources/pkg"
|
|
"github.com/google/uuid"
|
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// SourceCredential Data/Medical Provider Credentials
|
|
// similar to LighthouseSourceDefinition from fasten-source
|
|
type SourceCredential struct {
|
|
ModelBase
|
|
User *User `json:"user,omitempty"`
|
|
UserID uuid.UUID `json:"user_id" gorm:"uniqueIndex:idx_user_source_patient"`
|
|
Patient string `json:"patient" gorm:"uniqueIndex:idx_user_source_patient"`
|
|
EndpointID uuid.UUID `json:"endpoint_id" gorm:"uniqueIndex:idx_user_source_patient"`
|
|
|
|
//New Fields
|
|
Display string `json:"display"`
|
|
LighthouseEnvType sourcesPkg.FastenLighthouseEnvType `json:"lighthouse_env_type"`
|
|
BrandID uuid.UUID `json:"brand_id"`
|
|
PortalID uuid.UUID `json:"portal_id"`
|
|
PlatformType sourcesPkg.PlatformType `json:"platform_type"`
|
|
|
|
LatestBackgroundJob *BackgroundJob `json:"latest_background_job,omitempty"`
|
|
LatestBackgroundJobID *uuid.UUID `json:"-"`
|
|
|
|
// auth/credential data
|
|
ClientId string `json:"client_id"`
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
IdToken string `json:"id_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
CodeChallenge string `json:"code_challenge"`
|
|
CodeVerifier string `json:"code_verifier"`
|
|
|
|
//dynamic client auth/credential data
|
|
DynamicClientJWKS []map[string]string `json:"dynamic_client_jwks" gorm:"type:text;serializer:json"`
|
|
DynamicClientId string `json:"dynamic_client_id"`
|
|
}
|
|
|
|
func (s *SourceCredential) GetSourceId() string {
|
|
return s.ID.String()
|
|
}
|
|
|
|
func (s *SourceCredential) GetEndpointId() string {
|
|
return s.EndpointID.String()
|
|
}
|
|
|
|
func (s *SourceCredential) GetPortalId() string {
|
|
return s.PortalID.String()
|
|
}
|
|
|
|
func (s *SourceCredential) GetBrandId() string {
|
|
return s.BrandID.String()
|
|
}
|
|
|
|
func (s *SourceCredential) GetPlatformType() sourcesPkg.PlatformType {
|
|
return s.PlatformType
|
|
}
|
|
|
|
func (s *SourceCredential) GetClientId() string {
|
|
return s.ClientId
|
|
}
|
|
|
|
func (s *SourceCredential) GetPatientId() string {
|
|
return s.Patient
|
|
}
|
|
|
|
func (s *SourceCredential) GetRefreshToken() string {
|
|
return s.RefreshToken
|
|
}
|
|
|
|
func (s *SourceCredential) GetAccessToken() string {
|
|
return s.AccessToken
|
|
}
|
|
|
|
func (s *SourceCredential) GetExpiresAt() int64 {
|
|
return s.ExpiresAt
|
|
}
|
|
|
|
func (s *SourceCredential) SetTokens(accessToken string, refreshToken string, expiresAt int64) {
|
|
if expiresAt > 0 && expiresAt != s.ExpiresAt {
|
|
s.ExpiresAt = expiresAt
|
|
}
|
|
|
|
if accessToken != s.AccessToken {
|
|
// update the "source" credential with new data (which will need to be sent
|
|
s.AccessToken = accessToken
|
|
// Don't overwrite `RefreshToken` with an empty value
|
|
// if this was a token refreshing request.
|
|
if refreshToken != "" {
|
|
s.RefreshToken = refreshToken
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
serverUrl: connectData.message.api_endpoint_base_url,
|
|
clientId: connectData.message.client_id,
|
|
redirectUri: connectData.message.redirect_uri,
|
|
tokenUri: `${connectData.message.oauth_endpoint_base_url}/token`,
|
|
scope: connectData.message.scopes.join(' '),
|
|
tokenResponse: payload,
|
|
expiresAt: getAccessTokenExpiration(payload, new BrowserAdapter()),
|
|
codeChallenge: codeChallenge,
|
|
codeVerifier: codeVerifier
|
|
*/
|
|
|
|
// IsDynamicClient this method is used to check if this source uses dynamic client registration (used to customize token refresh logic)
|
|
func (s *SourceCredential) IsDynamicClient() bool {
|
|
|
|
endpoint, err := sourcesDefinitions.GetSourceDefinition(sourcesDefinitions.GetSourceConfigOptions{
|
|
EndpointId: s.EndpointID.String(),
|
|
})
|
|
if err != nil || endpoint == nil {
|
|
return false
|
|
}
|
|
return len(endpoint.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 {
|
|
|
|
endpoint, err := sourcesDefinitions.GetSourceDefinition(sourcesDefinitions.GetSourceConfigOptions{
|
|
EndpointId: s.EndpointID.String(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("an error occurred while retrieving source definition: %w", err)
|
|
} else if endpoint == nil {
|
|
return fmt.Errorf("endpoint definition not found")
|
|
} else if endpoint.RegistrationEndpoint == "" {
|
|
return fmt.Errorf("registration endpoint not found")
|
|
}
|
|
|
|
//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, endpoint.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("this institution may not support dynamic client registration, meaning that we cannot automatically fetch your records. Please contact support@fastenhealth.com and we'll modify this provider to use our Legacy integration: %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 {
|
|
endpoint, err := sourcesDefinitions.GetSourceDefinition(sourcesDefinitions.GetSourceConfigOptions{
|
|
EndpointId: s.EndpointID.String(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("an error occurred while retrieving source definition: %w", err)
|
|
}
|
|
|
|
if len(endpoint.DynamicClientRegistrationMode) == 0 {
|
|
return fmt.Errorf("dynamic client registration mode not set")
|
|
}
|
|
if len(s.DynamicClientJWKS) == 0 {
|
|
return fmt.Errorf("dynamic client jwks not set")
|
|
}
|
|
if len(s.DynamicClientId) == 0 {
|
|
return fmt.Errorf("dynamic client id not set")
|
|
}
|
|
|
|
//convert the serialized dynamic-client credentials to a jwx.Key
|
|
jwkeypair, err := jwk.JWKDeserialize(s.DynamicClientJWKS[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// see https://github.com/lestrrat-go/jwx/tree/v2/jwt#token-usage
|
|
t := jwt.New()
|
|
t.Set("kid", jwkeypair.KeyID())
|
|
t.Set(jwt.SubjectKey, s.DynamicClientId)
|
|
t.Set(jwt.AudienceKey, endpoint.TokenEndpoint)
|
|
t.Set(jwt.JwtIDKey, uuid.New().String())
|
|
t.Set(jwt.ExpirationKey, time.Now().Add(time.Minute*2).Unix()) // must be less than 5 minutes from now. Time when this JWT expires
|
|
t.Set(jwt.IssuedAtKey, time.Now().Unix())
|
|
t.Set(jwt.IssuerKey, s.DynamicClientId)
|
|
|
|
//sign the jwt with the private key
|
|
// Signing a token (using raw rsa.PrivateKey)
|
|
signed, err := jwt.Sign(t, jwt.WithKey(jwa.RS256, jwkeypair))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sign dynamic-client jwt: %s", err)
|
|
}
|
|
|
|
//send this signed jwt to the token endpoint to get a new access token
|
|
// https://fhir.epic.com/Documentation?docId=oauth2§ion=JWKS
|
|
|
|
postForm := url.Values{
|
|
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
|
"assertion": {string(signed)},
|
|
"client_id": {s.DynamicClientId},
|
|
}
|
|
|
|
tokenResp, err := http.PostForm(endpoint.TokenEndpoint, postForm)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("an error occurred while sending dynamic client token request, %s", err)
|
|
}
|
|
|
|
defer tokenResp.Body.Close()
|
|
if tokenResp.StatusCode >= 300 || tokenResp.StatusCode < 200 {
|
|
|
|
b, err := io.ReadAll(tokenResp.Body)
|
|
if err == nil {
|
|
log.Printf("Error Response body: %s", string(b))
|
|
}
|
|
|
|
return fmt.Errorf("an error occurred while reading dynamic client token response, status code was not 200: %d", tokenResp.StatusCode)
|
|
}
|
|
|
|
var registrationTokenResponseBytes ClientRegistrationTokenResponse
|
|
err = json.NewDecoder(tokenResp.Body).Decode(®istrationTokenResponseBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("an error occurred while parsing dynamic client token response: %v", err)
|
|
}
|
|
|
|
//update the source credential with the new access token
|
|
s.AccessToken = registrationTokenResponseBytes.AccessToken
|
|
s.ExpiresAt = time.Now().Add(time.Second * time.Duration(registrationTokenResponseBytes.ExpiresIn)).Unix()
|
|
|
|
return nil
|
|
}
|