2022-12-02 20:40:58 -07:00
package models
import (
2023-10-11 21:43:27 -06:00
"bytes"
2023-07-19 23:45:14 -06:00
"encoding/json"
"fmt"
2023-08-27 18:09:46 -06:00
"github.com/fastenhealth/fasten-onprem/backend/pkg/jwk"
2023-10-08 17:29:26 -06:00
sourcesPkg "github.com/fastenhealth/fasten-sources/pkg"
2022-12-02 20:40:58 -07:00
"github.com/google/uuid"
2023-07-19 23:45:14 -06:00
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
"io"
"log"
"net/http"
"net/url"
"time"
2022-12-02 20:40:58 -07:00
)
// SourceCredential Data/Medical Provider Credentials
// similar to LighthouseSourceDefinition from fasten-source
type SourceCredential struct {
ModelBase
2023-10-08 17:29:26 -06:00
User * User ` json:"user,omitempty" `
UserID uuid . UUID ` json:"user_id" gorm:"uniqueIndex:idx_user_source_patient" `
SourceType sourcesPkg . SourceType ` json:"source_type" gorm:"uniqueIndex:idx_user_source_patient" `
Patient string ` json:"patient" gorm:"uniqueIndex:idx_user_source_patient" `
LatestBackgroundJob * BackgroundJob ` json:"latest_background_job,omitempty" `
LatestBackgroundJobID * uuid . UUID ` json:"-" `
2022-12-02 20:40:58 -07:00
//oauth endpoints
AuthorizationEndpoint string ` json:"authorization_endpoint" `
TokenEndpoint string ` json:"token_endpoint" `
IntrospectionEndpoint string ` json:"introspection_endpoint" `
2023-07-19 23:45:14 -06:00
RegistrationEndpoint string ` json:"registration_endpoint" ` //optional - required when Dynamic Client Registration mode is set
2022-12-02 20:40:58 -07:00
Scopes [ ] string ` json:"scopes_supported" gorm:"type:text;serializer:json" `
Issuer string ` json:"issuer" `
GrantTypesSupported [ ] string ` json:"grant_types_supported" gorm:"type:text;serializer:json" `
ResponseType [ ] string ` json:"response_types_supported" gorm:"type:text;serializer:json" `
ResponseModesSupported [ ] string ` json:"response_modes_supported" gorm:"type:text;serializer:json" `
Audience string ` json:"aud" ` //optional - required for some providers
CodeChallengeMethodsSupported [ ] string ` json:"code_challenge_methods_supported" gorm:"type:text;serializer:json" `
//Fasten custom configuration
UserInfoEndpoint string ` json:"userinfo_endpoint" ` //optional - supported by some providers, not others.
ApiEndpointBaseUrl string ` json:"api_endpoint_base_url" ` //api endpoint we'll communicate with after authentication
ClientId string ` json:"client_id" `
RedirectUri string ` json:"redirect_uri" ` //lighthouse url the provider will redirect to (registered with App)
2023-07-19 23:45:14 -06:00
Confidential bool ` json:"confidential" ` //if enabled, requires client_secret to authenticate with provider (PKCE)
DynamicClientRegistrationMode string ` json:"dynamic_client_registration_mode" ` //if enabled, will dynamically register client with provider (https://oauth.net/2/dynamic-client-registration/)
CORSRelayRequired bool ` json:"cors_relay_required" ` //if true, requires CORS proxy/relay, as provider does not return proper response to CORS preflight
2022-12-02 20:40:58 -07:00
//SecretKeyPrefix string `json:"-"` //the secret key prefix to use, if empty (default) will use the sourceType value
// auth/credential data
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" `
2023-07-19 23:45:14 -06:00
//dynamic client auth/credential data
DynamicClientJWKS [ ] map [ string ] string ` json:"dynamic_client_jwks" gorm:"type:text;serializer:json" `
DynamicClientId string ` json:"dynamic_client_id" `
2022-12-02 20:40:58 -07:00
}
2023-10-08 17:29:26 -06:00
func ( s * SourceCredential ) GetSourceType ( ) sourcesPkg . SourceType {
2022-12-02 20:40:58 -07:00
return s . SourceType
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetClientId ( ) string {
2022-12-02 20:40:58 -07:00
return s . ClientId
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetPatientId ( ) string {
2022-12-02 20:40:58 -07:00
return s . Patient
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetOauthAuthorizationEndpoint ( ) string {
2022-12-02 20:40:58 -07:00
return s . AuthorizationEndpoint
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetOauthTokenEndpoint ( ) string {
2022-12-02 20:40:58 -07:00
return s . TokenEndpoint
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetApiEndpointBaseUrl ( ) string {
2022-12-02 20:40:58 -07:00
return s . ApiEndpointBaseUrl
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetRefreshToken ( ) string {
2022-12-02 20:40:58 -07:00
return s . RefreshToken
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetAccessToken ( ) string {
2022-12-02 20:40:58 -07:00
return s . AccessToken
}
2023-07-14 20:52:02 -06:00
func ( s * SourceCredential ) GetExpiresAt ( ) int64 {
2022-12-02 20:40:58 -07:00
return s . ExpiresAt
}
2023-07-19 23:45:14 -06:00
func ( s * SourceCredential ) SetTokens ( accessToken string , refreshToken string , expiresAt int64 ) {
2023-07-14 20:52:02 -06:00
if expiresAt > 0 && expiresAt != s . ExpiresAt {
s . ExpiresAt = expiresAt
}
2022-12-02 20:40:58 -07:00
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
* /
2023-07-19 23:45:14 -06:00
// 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 {
return len ( s . DynamicClientRegistrationMode ) > 0
}
2023-10-11 21:43:27 -06:00
// 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 ( & registrationResponseBytes )
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
}
2023-10-08 17:29:26 -06:00
// this will set/update the AccessToken and Expiry using the dynamic client credentials
2023-10-11 21:43:27 -06:00
// it will set the following fields:
// - AccessToken
// - ExpiresAt
2023-07-19 23:45:14 -06:00
func ( s * SourceCredential ) RefreshDynamicClientAccessToken ( ) error {
if len ( s . 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 , s . 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 ( s . 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 )
2023-07-20 22:43:37 -06:00
if err == nil {
log . Printf ( "Error Response body: %s" , string ( b ) )
2023-07-19 23:45:14 -06:00
}
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 ( & registrationTokenResponseBytes )
if err != nil {
2023-07-20 23:08:31 -06:00
return fmt . Errorf ( "an error occurred while parsing dynamic client token response: %v" , err )
2023-07-19 23:45:14 -06:00
}
//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
}