Question Gigi La Course · Apr 16

Custom HTTPS Business Operation that supports OAUTH 2.0 Client Credentials

Has anyone done this successfully?  Customer wants to receive HL7 data over https using OAUTH 2.0 client credentials.  I am assuming a custom Operation will be needed?  I tried using CoPilot for assistance but was unsuccessful.  I am not great with Object script :( 

Product version: IRIS 2024.3
$ZV: IRIS for UNIX (Red Hat Enterprise Linux 9 for x86-64) 2024.1.3 (Build 456U) Thu Jan 9 2025 12:22:38

Comments

DC AI Bot · Apr 16 Bot

To implement a custom HTTPS Business Operation supporting OAuth 2.0 Client Credentials for receiving HL7 data, a streamlined approach can leverage InterSystems IRIS’s built-in functionalities.

Key Steps:

  1. Configuration of OAuth 2.0 Settings:
    The recent version, InterSystems IRIS 2024.3, introduces simplified settings for OAuth 2.0 authentication with Client Credentials. In your Business Operation (based on the HTTP Outbound Adapter), new OAuth settings allow you to specify:

    • Access Token Placement (Header | Body | Query)
    • Grant Type (Password | JWT Authorization | Client Credentials) 1.
  2. OAuth 2.0 Client Definition:
    You must configure the OAuth Client within the System Management Portal:

    • Navigate to System Administration -> Security -> OAuth 2.0 -> Client.
    • Define the Issuer Endpoint of the OAuth server, SSL/TLS settings, and supported grant type “Client Credentials.”
    • Enter the client-specific configuration (Client ID, Client Secret), which align with the requirements from the remote authentication server 2.
  3. Authorization and Token Handling in Custom Code:
    In the custom Business Operation:

    • Utilize the HTTP Outbound Adapter to handle requests.
    • Use the %SYS.OAuth2 classes to manage tokens. For example:
      Method AuthorizeMe(Output AccessToken As %String) As %Status {
          Set isAuth = ##class(%SYS.OAuth2.AccessToken).IsAuthorized("TestClientA",,,.accessToken,.responseProperties,.error)
          If 'isAuth {
              $$$QuitOnError(##class(%SYS.OAuth2.Authorization).GetAccessTokenClient("TestClientA",,,.error))
              $$$QuitOnError(##class(%SYS.OAuth2.AccessToken).IsAuthorized("TestClientA",,,.accessToken,.responseProperties,.error))
          }
          Set AccessToken = accessToken
          Quit $$$OK
      }
      
      • Replace "TestClientA" with the Client Name configured in the Management Portal 2.
  4. Payload Transmission:
    Ensure to place the access token in the request’s HTTP Headers for authentication:

    Set tSC = pRequest.HTTPHeaders.SetAt("Bearer "_AccessToken, "Authorization")
    

    This lets the remote server authenticate your application using OAuth Client Credentials 2.

Benefits:

  • You don’t need to write complex custom scripts for OAuth handling; you can rely on InterSystems IRIS built-in features and toolkits.
  • These configurations simplify the exchange of HL7 data securely using HTTPS and OAuth 2.0.

Additional References:

If issues arise during implementation or additional customizations are needed, consult the OAuth Integration documentation and the System Administration settings for advanced configurations 12.


Sources:

Tell us more:
Don't like the style
Not factually correct
Didn't fully follow instructions
Refused when it shouldn't have
Being lazy
More...
Submit
0 0
Julian Matthews · Apr 17

Hey Gigi.

In the past I have had to create a custom HL7 HTTP Operation that heavily relies on the code used by the built in Operation. Doing so comes with some risk risk, but in my use case it was required (the response could only be read using %Parser.ParseIOStream and not %Parser.ParseFramedIOStream). My change was effectively a copy of the SendMessage Method, with a single line changed.

In your case, I think you will want to do similar, but instead I think the following will need to be your approach:

  1. Create a new Operation that copies EnsLib.HL7.Operation.HTTPOperation.
  2. Add in a new Method that follows the format of what the bot has shared for the Method "AuthorizeMe", but tweaked to reference the client setup for OAuth2 
  3. At the start of the existing Method "SendMessage", as a line at the start that calls the "AuthorizeMe" Method. Something like:
    1. $$$QuitOnError(..AuthorizeMe())
  4. Add a new line prior to the line that makes the HTTP Request which adds in the authorisation header. Something like:
    1. Set tSC = tHttpRequest.SetHeader("Authorization","Bearer "_AccessToken)

This should hopefully add in the OAuth2 bearer token to the Http request as you need.

You will still need to configure your instance to be able to generate the OAuth token to be able to then make use of this.

If this is your first time working with OAuth2 in IRIS, I would start with making sure you are able to set this up and generate a token. Once configured within the management portal, you can try testing this by manually running the calls being made in the "AuthorizeMe" method and seeing if you get a valid token back (or add it to an adhoc classmethod so you can call that on each test instead of needing to manually run the required lines).

0
Julian Matthews  Apr 17 to Julian Matthews

Once you have this setup working, I would say that you could add in a slight improvement and make it so that the Client is a configurable item for the Operation and not hard coded.

0
Dean White · Apr 20

I've written this, it still requires a bit of work for dealing with errors correctly as it does not yet use the reply action codes correctly.
 

ClassComponent.Oauth.HL7HttpOperationPassThroughExtendsEnsLib.HL7.Operation.HTTPOperation
{
///Content-Type header of the payload
PropertyContentTypeAs%String[InitialExpression="x-application/hl7-v2+er7; charset=utf-8",Required];
PropertyOAuthClientApplicationNameAs%String(MAXLEN=64,MINLEN=1)[InitialExpression="SystemApplicationName",Required];
PropertyOAuthScopeAs%String(MAXLEN=1024);
ParameterADAPTER="EnsLib.HTTP.OutboundAdapter";
PropertyAdapterAsEnsLib.HTTP.OutboundAdapter;
///Default is don't need all the info off the response so just return the Status line
PropertyCondensedHTMLResponseAs%Boolean[InitialExpression=1,Required];
///JSON formatted string of parameters to be sent with the OAuth2 request
///e.g. {"ContentType":"x-application/hl7-v2+er7; charset=utf-8","OAuthClientApplicationName":"SystemApplicationName","OAuthScope":"OAuth","OAuthTokenSessionId":"SYSTOKENID"}
PropertyParamsAs%String(MAXLEN=500000);
PropertyOAuthTokenSessionIdAs%String[InitialExpression="SYSTOKENID",Required];
ParameterSETTINGS="ContentType:Basic,OAuthClientApplicationName:OAuth,OAuthScope:OAuth,OAuthTokenSessionId:OAuth, Params:OAuth";
MethodSendMessage(pMsgOutAsEnsLib.HL7.Message,OutputpMsgInAsEnsLib.HL7.Message,pExpectedSequenceNumberAs%String)As%Status
{
    $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:SendMessage")
   
    Set pMsgIn=$$$NULLOREF, tHttpRequest=##class(%Net.HttpRequest).%New(), tHttpRequest.WriteRawMode=1
    // Add these two lines after the first in SendMessage. Change the content type to your desired header.
    Set tSC = tHttpRequest.SetHeader("Content-Type", ..ContentType)
    s tHttpRequest.EntityBody=##class(%IO.StringStream).%New()
    s tHttpResponse =##class(%Net.HttpResponse).%New()
    set sc=tHttpRequest.EntityBody.Write(pMsgOut.RawContent)
    s tHttpRequest.Port=443
    s tHttpRequest.FollowRedirect=1
   
    $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:SendMessage Rawcontent " _ tHttpRequest.EntityBody.Size _ " " _ tHttpRequest.EntityBody.Read(1000))
    Set tSC = ..AddAccessToken(.tHttpRequest)
    Set sessionId=..%SessionId
   
    $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken " _ tSC _ " " _ sessionId)
     
    Quit:$$$ISERR(tSC) tSC
    // Copy the rest of the SendMessage Method from the standard EnsLib.HL7.Operation.HTTPOperation
   // Set tMetaIOStream=##class(%IO.MetaCharacterStream).%New(tHttpRequest.EntityBody)
    //Set tSC=..OutputFramedToIOStream(tMetaIOStream,pMsgOut,..Separators,pExpectedSequenceNumber,1,..IOLogEntry) Quit:$$$ISERR(tSC) tSC
   
    // Initialize the EntityBody as a StringStream
    Set tHttpRequest.EntityBody = ##class(%IO.StringStream).%New()
    // Convert HL7 message to a properly formatted string with segment separators
    Set hl7Content = "",seg=""
    $$$TRACE(pMsgOut.SegCountGet())
    For segmentIndex = 1:1:pMsgOut.SegCountGet() {
        Set segment = pMsgOut.GetSegmentAt(segmentIndex)
        Set hl7Content = hl7Content _ segment.OutputToString() _ " "// Append segment and carriage return
        s seg=""_segment.OutputToString()_" "
       
        Set tSC = tHttpRequest.EntityBody.Write(seg) Quit:$$$ISERR(tSC)
    }
    //$$$TRACE("Formatted HL7 Content: " _ hl7Content)
    // Write the HL7 content to the EntityBody
    //Set tSC = tHttpRequest.EntityBody.Write(hl7Content)
    //If $$$ISERR(tSC) Quit tSC
    //$$$TRACE(tHttpRequest.EntityBody.Read(10000))
    //Set tSC = tHttpRequest.EntityBody.Write(hl7Content)
   
    Set tHttpRequest.ResponseStream=##class(%IO.StringStream).%New()
    $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:Authorization " _ tHttpRequest.Authorization _ " " _ sessionId)
   
    Set tHttpResponse = ##class(%Net.HttpResponse).%New()
    S send="POST"
    $$$TRACE("Sending to "_..Adapter.URL)
    s tSC = ..SendRequest(.tHttpResponse,send,tHttpRequest, .pResponse)
    //$$$TRACE(tHttpResponse.Data)
   
    // If response body is empty, write status info into stream
    If tHttpResponse.Data.Size = 0 && tHttpResponse.StatusCode >0 {
        Do tHttpResponse.Data.Write(tHttpResponse.StatusCode _ " " _ tHttpResponse.StatusLine)
    }
    #; Account for Adapter generating an error based on StatusCode
    If $$$ISERR(tSC),'$$$StatusEquals(tSC,$$$EnsErrHTTPStatus) Quit tSC
    If $IsObject(tHttpResponse.Data),tHttpResponse.Data.Size {
        $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:Reponse " _ tHttpResponse.StatusCode _ " " _ sessionId)
        //check framing....
        $$$TRACE("RESPONSE FRAME")
        $$$TRACE(tHttpResponse.Data.Read())
        do tHttpResponse.Data.Rewind()
        $$$TRACE(tHttpResponse.Data.Read())
        do tHttpResponse.Data.Rewind()
        set tHL7=tHttpResponse.Data.Read()
        Set tSC1=..%Parser.ParseFramedIOStream(tHttpResponse.Data,.pMsgIn,0,..IOLogEntry) Quit:$$$ISERR(tSC1) $$$ADDSC(tSC,tSC1)
    }
    Set:$$$StatusEquals(tSC,$$$EnsErrHTTPStatus) tSC=$$$OK
    Quit:$$$ISERR(tSC) tSC
    #; If no body response message, construct an ACK message from the HTTP Status Code
    If '$IsObject(pMsgIn) {
        //$$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:Status " _ HttpResponse.StatusCode _ " " _ HttpResponse.StatusLine _ " " _ sessionId)
        Set pMsgIn=pMsgOut.NewReplyDocument()
        Set pMsgIn.Envelope="ACK_HTTP_"_+tHttpResponse.StatusCode_":"_tHttpResponse.StatusLine
        $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:Status " _ pMsgIn.Envelope)
        //Set tCode="A"_$Case(+tHttpResponse.StatusCode, 200:"A", 503:"R", :"E")
        //Set tCode = $Case(+tHttpResponse.StatusCode,  200:"AA", 204:"AA", 503:"AR", :"AE")
        // Full HTTP → HL7 MSA mapping
        Set tCode = $Case(+tHttpResponse.StatusCode,
            200:"AA", 201:"AA", 202:"AA", 204:"AA",
            400:"AE", 401:"AE", 403:"AE", 404:"AE",
            408:"AR", 429:"AR",
            500:"AR", 502:"AR", 503:"AR", 504:"AR",
            :"AE")
        #; Create a message object to represent the HTTP ACK ; set 00 control id, 2.1 version
        Do pMsgIn.SetValueAt($TR($P(pMsgIn.Envelope,":"),"_",pMsgIn.CS),"1:9")
        Set tControlId=pMsgOut.GetValueAt("1:10") Set tControlId=$S(""'=tControlId:tControlId,1:"00")
        Do pMsgIn.SetValueAt("00","1:10")
        Do pMsgIn.SetValueAt(2.1,"1:12")
        Set tDesc="HTTP "_$S("AA"=tCode:"",1:"(N)")_"ACK '"_tHttpResponse.StatusLine_"'"
        If $IsObject(tHttpResponse.Data) {
            Do tHttpResponse.Data.Rewind()
            Set tDesc=tDesc_$S('tHttpResponse.Data.Size:"",1:" : "_tHttpResponse.Data.Read(1000))
        }
        Set tMSAText="MSA"_pMsgIn.FS_tCode_pMsgIn.FS_tControlId_pMsgIn.FS_tDesc
        Set tAckMSA=##class(EnsLib.HL7.Segment).%New($LB(,1,,pMsgIn.Separators_tMSAText))
        Do pMsgIn.AppendSegment(tAckMSA)
    }
    Quit tSC
}
MethodAddAccessToken(ByRefpHttpRequestAs%Net.HttpRequest)As%Status
{
    $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken")
    Set tSC = $$$OK
    #dim erroras%OAuth2.Error
    Try {
       
        ; sessionId has a 50 charactor limit
        ; This should support multiple requests in the same production session  
        Set sessionId=..OAuthTokenSessionId
        Set isAuthorised = ##class(%SYS.OAuth2.AccessToken).IsAuthorized(..OAuthClientApplicationName,sessionId,..OAuthScope,.accessToken,,.responseProperties,.error)
        ;Throw:'$$$NULLOREF=error ..ExceptionFromOAuth2Error(error)
        Throw:$isobject(error) ..ExceptionFromOAuth2Error(error)
        If 'isAuthorised {
            $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken isAuthorised false")
            //Add any Parameters
            s paramsarr = [].%FromJSON(..Params)
            s iterator = paramsarr.%GetIterator()
            s properties=""
            While iterator.%GetNext(.key,.value)
            {
                s properties(key)=value
            }
            Set tSC = ##class(%SYS.OAuth2.Authorization).GetAccessTokenClient(..OAuthClientApplicationName,..OAuthScope,.properties,.error,.sessionId)    
            $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken isAuthorised " _ tSC)
            Throw:$isobject(error) ..ExceptionFromOAuth2Error(error)
        }
        ; sendType is the mode of sending access token to resource server
        ; The default is "header"
        Set sendType = "header"
        ;The default for sslConfiguration comes from the OAuth2.Client instance.        
        Set tSC  = ##class(%SYS.OAuth2.AccessToken).AddAccessToken(pHttpRequest,sendType,,..OAuthClientApplicationName,sessionId)
    } Catch exception {
        Set tSC = exception.AsStatus()
        $$$TRACE("EXCEPTION")
        $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken Code " _ exception.Code)
        $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken Location " _ exception.Location)
        $$$TRACE("Component.Oauth.HL7HttpOperationPassThrough:AddAccessToken Data " _ exception.Data)
    }
    Return tSC
}
MethodSendRequest(OutputtHttpResponseAs%Net.HttpResponse,sendAs%String,tHttpRequestAs%Net.HttpRequest,OutputpResponseAsEnsLib.HTTP.GenericMessage)As%Status
{
    Set tSC=$$$OK
    // Perform the send
    Set tSC = ..Adapter.SendFormDataArray(.tHttpResponse, send, tHttpRequest)
    $$$TRACE("done the send")
    // If adapter returned error AND there's HTTP data, wrap the data in a status
    If $$$ISERR(tSC) && $IsObject(tHttpResponse) && $IsObject(tHttpResponse.Data) && tHttpResponse.Data.Size {
        Set tSC = $$$ERROR($$$EnsErrGeneral, $$$StatusDisplayString(tSC)_":"_tHttpResponse.Data.Read())
        Return tSC   // <-- FIX
    }
    If $$$ISERR(tSC) {
        Return tSC   // <-- FIX
    }
    // Handle HTTP status codes
    If (tHttpResponse.StatusCode < 300) {
        Set tSC=$$$OK
    } Else {
        Set message=tHttpResponse.StatusCode_": "_tHttpResponse.StatusLine_". "
        If $IsObject(tHttpResponse.Data) {
            Try {
                While 'tHttpResponse.Data.AtEnd {
                    Set message = message _ tHttpResponse.Data.Read(,.tSC1)
                }
            } Catch ex {
                // ignore
            }
        }
    }
    // Always RETURN a value
    Return ..constructResponse(tHttpResponse,.pResponse)
}
MethodconstructResponse(pHttpResponseAs%Net.HttpResponse,pResponseAsEnsLib.HTTP.GenericMessage)As%Status[Internal]
{
    $$$TRACE("construct response")
    Set tSC=$$$OK
    if '..CondensedHTMLResponse{
    If $IsObject(pHttpResponse.Data) {
        Set tStream=pHttpResponse.Data
    } Else {
        Set tStream=##class(%Stream.GlobalBinary).%New()
        Set tSC=tStream.Write(pHttpResponse.Data)  Quit:$$$ISERR(tSC) tSC
    }
    Set pResponse=##class(EnsLib.HTTP.GenericMessage).%New(tStream,,pHttpResponse)
    }
    else{
    Set pResponse=##class(EnsLib.HTTP.GenericMessage).%New()
        }
    Do pResponse.HTTPHeaders.SetAt(pHttpResponse.StatusCode,"StatusCode")
    Do pResponse.HTTPHeaders.SetAt(pHttpResponse.StatusLine,"StatusLine")
    Quit tSC
}
MethodExceptionFromOAuth2Error(pErrorAs%OAuth2.Error)As%Exception.StatusException
{
    Set errorText = pError.AsString()
    $$$LOGERROR(errorText)
    Set tSC = $$$ERROR($$$GeneralError,errorText)
    Return ##class(%Exception.StatusException).CreateFromStatus(tSC)
    ;Or
    ;If you wish to create one with %New then the 4th argument is a $lb of data values to the error %Status, e.g.
    ;Set exception = ##class(%Exception.StatusException).%New(Name,Code,Location,$lb(arg1,arg2,arg3,arg4))
}
}
0