Article
· Oct 20, 2023 11m read

Securing Individual REST API Endpoints

I was attempting to find a solution to grant clients anonymous access to certain API endpoints while securing others within my REST API. However, when defining a Web Application, you can only secure the entire application and not specific parts of it.

I scoured the community for answers but didn't find any exact solutions, except one recommendation to create two separate web applications, one secured and the other unsecured. However, in my opinion, this approach involves too much work and creates unnecessary maintenance overhead. I prefer to develop my APIs spec-first and decide within the specification which endpoints should allow anonymous access and which should not.

In this article, I provide two examples: one for Basic Auth and the other for JWT, which is used in OAuth 2.0 context. If you notice any flaws in these examples, please let me know, and I will make the necessary fixes accordingly.

Prerequisites

First, define a Web Application for your REST API. Configure it for unauthenticated access and specify the required privileges for the application. Specify only the roles and resources necessary for the successful use of the API.

Create a class, for example REST.Utils where you will implement the helper classmethods that verify the credentials.

Class REST.Utils  
{

}

Basic Auth

If you want to secure a endpoint using Basic Auth, use the following method to check if the username/password provided in the HTTP Authorization header has the correct privileges to access the restricted API endpoint.

/// Check if the user has the required permissions.
/// - auth: The Authorization header.
/// - resource: The resource to check permissions for.
/// - permissions: The permissions to check.
/// 
/// Example:
/// > Do ##class(REST.Utils).CheckBasicCredentials(%request.GetCgiEnv("HTTP_AUTHORIZATION", ""), "RESOURCE", "U")
/// 
/// Return: %Status. The status of the check.
ClassMethod CheckBasicCredentials(auth As %String, resource As %String, permissions As %String) As %Status
{
  /// Sanity check the input  
  if (auth = "") {
    Return $$$ERROR($$$GeneralError, "No Authorization header provided")
  }

  /// Check if the auth header starts with Basic  
  if ($FIND(auth, "Basic") > 0) {
    /// Strip the "Basic" part from the Authorization header and remove trailing and leading spaces.  
    set auth = $ZSTRIP($PIECE(auth, "Basic", 2), "<>", "W")
  }

  Set tStatus = $$$OK

  /// Decode the base64 encoded username and password  
  Set auth = $SYSTEM.Encryption.Base64Decode(auth)
  Set username = $PIECE(auth, ":", 1)
  Set password = $PIECE(auth, ":", 2)

  /// Attempt to log in as the user provided in the Authorization header  
  Set tStatus = $SYSTEM.Security.Login(username, password)

  if $$$ISERR(tStatus) {
    Return tStatus
  }

  /// Check if the user has the required permissions  
  Set tStatus = $SYSTEM.Security.CheckUserPermission($USERNAME, resource, permissions)

  /// Return the status. If the user has the required permissions, the status will be $$$OK  
  Return tStatus
}

In the endpoint you want to secure, call the CheckBasicCredentials-method and check the return value. A return value of 0 indicates a failed check. In these cases, we return an HTTP 401 back to the client.

The example below checks that the user has SYSTEM_API resource defined with USE privileges. If it does not, return HTTP 401 to the client. Remember that the API user has to have %Service_Login:USE privilege to be able to use the Security.Login method.

Example

  Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
  Set tStatus = ##class(REST.Utils).CheckBasicCredentials(authHeader, "SYSTEM_API", "USE")
  if ($$$ISERR(tStatus)) {
    Set %response.Status = 401
    Return
  }
  ... rest of the code

JWT

Instead of using Basic Auth to secure an endpoint, I prefer to use OAuth 2.0 JWT Access Tokens, as they are more secure and provides a more flexible way to define privileges via scopes. The following method checks if the JWT access token provided in the HTTP Authorization header has the correct privileges to access the restricted API endpoint.

/// Check if the supplied JWT is valid.
/// - auth: The Authorization header.
/// - scopes: The scopes that this JWT token should have.
/// - oauthClient: The OAuth client that is used to validate the JWT token. (optional)
/// - jwks: The JWKS used for token signature validation (optional)
/// 
/// Example:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckJWTCredentials(token, "scope1,scope2")
/// 
/// Return: %Status. The status of the check.
ClassMethod CheckJWTCredentials(token As %String, scopes As %String, oauthClient As %String = "", jwks As %String = "") As %Status
{
  Set tStatus = $$$OK

  /// Sanity check the input  
  if (token = "") {
    Return $$$ERROR($$$GeneralError, "No token provided")
  }

  /// Check if the auth header starts with Bearer. Cleanup the token if yes.  
  if ($FIND(token, "Bearer") > 0) {
    /// Strip the "Bearer" part from the Authorization header and remove trailing and leading spaces.  
    set token = $ZSTRIP($PIECE(token, "Bearer", 2), "<>", "W")
  }

  /// Build a list from the string of scopes  
  Set scopes = $LISTFROMSTRING(scopes, ",")

  Set scopeList = ##class(%ListOfDataTypes).%New()
  Do scopeList.InsertList(scopes)

  /// Strip whitespaces from each scope  
  For i=1:1:scopeList.Count() {
    Do scopeList.SetAt($ZSTRIP(scopeList.GetAt(i), "<>", "W"), i)
  }

  /// Decode the token  
  Try {
    Do ..JWTToObject(token, .payload, .header)
  } Catch ex {
    Return $$$ERROR($$$GeneralError, "Not a valid JWT token. Exception code: " _ ex.Code _ ". Status: " _ ex.AsStatus())
  }

  /// Get the epoch time of now
  Set now = $ZDATETIME($h,-2)

  /// Check if the token has expired  
  if (payload.exp < now) {
    Return $$$ERROR($$$GeneralError, "Token has expired")
  }

  Set scopesFound = 0

  /// Check if the token has the required scopes
  for i=1:1:scopeList.Count() {
    Set scope = scopeList.GetAt(i)
    Set scopeIter = payload.scope.%GetIterator()
    While scopeIter.%GetNext(.key, .jwtScope) {
      if (scope = jwtScope) {
        Set scopesFound = scopesFound + 1
      }
    }
  }

  if (scopesFound < scopeList.Count()) {
    Return $$$ERROR($$$GeneralError, "Token does not have the required scopes")
  }

  /// If the token is valid scope-wise and it hasn't expired, check if the signature is valid
  if (oauthClient '= "") {
    /// If we have specified a OAuth client, use that to validate the token signature
    Set result = ##class(%SYS.OAuth2.Validation).ValidateJWT(oauthClient, token, , , , , .tStatus,)
    if ($$$ISERR(tStatus) || result '= 1) {
      Return $$$ERROR($$$GeneralError, "Token failed signature validation")
    }
  } elseif (jwks '= "") {
    /// If we have specified a JWKS, use that to validate the token signature
    Set tStatus = ##class(%OAuth2.JWT).JWTToObject(token,,jwks,,,)
    if ($$$ISERR(tStatus)) {
      Return $$$ERROR($$$GeneralError, "Token failed signature validation. Reason: " _ $SYSTEM.Status.GetErrorText(tStatus))
    }
  }

  Return tStatus
}

/// Decode a JWT token.
/// - token: The JWT token to decode.
/// - payload: The payload of the JWT token. (Output)
/// - header: The header of the JWT token. (Output)
/// 
/// Example:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).JWTToObject(token, .payload, .header)
/// 
/// Return: %Status. The status of the check.
ClassMethod JWTToObject(token As %String, Output payload As %DynamicObject, Output header As %DynamicObject) As %Status
{
  Set $LISTBUILD(header, payload, sign) = $LISTFROMSTRING(token, ".")

  /// Decode and parse Header  
  Set header = $SYSTEM.Encryption.Base64Decode(header)
  Set header = {}.%FromJSON(header)

  /// Decode and parse Payload  
  Set payload = $SYSTEM.Encryption.Base64Decode(payload)
  Set payload = {}.%FromJSON(payload)

  Return $$$OK
}

Again, in the endpoint you want to secure, call the CheckJWTCredentials-method and check the return value. A return value of 0 indicates a failed check. In these cases, we return an HTTP 401 back to the client.

The example below checks if the token has the scopes scope1 and scope2 defined. If it lacks the required scopes, has expired, or fails signature validation, it returns an HTTP 401 status code to the client.

Example

  Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
  Set tStatus = ##class(REST.Utils).CheckJWTCredentials(authHeader, "scope1,scope2")
  if ($$$ISERR(tStatus)) {
    Set %response.Status = 401
    Return
  }
  ... rest of the code

Conclusion

Here is the full code for the REST.Utils class. If you have any suggestions on how to improve the code, please let me know. I will update the article accordingly.

One obvious improvement would be to check the JWT signature to make sure it is valid. To be able to do that, you need to have the public key of the issuer.

Class REST.Utils
{

/// Check if the user has the required permissions.
/// - auth: The Authorization header contents.
/// - resource: The resource to check permissions for.
/// - permissions: The permissions to check.
/// 
/// Example:
/// > Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckBasicCredentials(authHeader, "RESOURCE", "U"))
/// 
/// Return: %Status. The status of the check.  
ClassMethod CheckBasicCredentials(authHeader As %String, resource As %String, permissions As %String) As %Status
{
  Set auth = authHeader

  /// Sanity check the input  
  if (auth = "") {
    Return $$$ERROR($$$GeneralError, "No Authorization header provided")
  }

  /// Check if the auth header starts with Basic  
  if ($FIND(auth, "Basic") > 0) {
    // Strip the "Basic" part from the Authorization header and remove trailing and leading spaces.  
    set auth = $ZSTRIP($PIECE(auth, "Basic", 2), "<>", "W")
  }

  Set tStatus = $$$OK

  Try {
  /// Decode the base64 encoded username and password  
  Set auth = $SYSTEM.Encryption.Base64Decode(auth)
  Set username = $PIECE(auth,":",1)
  Set password = $PIECE(auth,":",2)
  } Catch {
    Return $$$ERROR($$$GeneralError, "Not a valid Basic Authorization header")
  }

  /// Attempt to login as the user provided in the Authorization header  
  Set tStatus = $SYSTEM.Security.Login(username,password)

  if $$$ISERR(tStatus) {
    Return tStatus
  }

  /// Check if the user has the required permissions  
  Set tStatus = $SYSTEM.Security.CheckUserPermission($USERNAME, resource, permissions)

  /// Return the status. If the user has the required permissions, the status will be $$$OK  
  Return tStatus
}

/// Check if the supplied JWT is valid.
/// - auth: The Authorization header.
/// - scopes: The scopes that this JWT token should have.
/// - oauthClient: The OAuth client that is used to validate the JWT token. (optional)
/// - jwks: The JWKS used for token signature validation (optional)
/// 
/// Example:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckJWTCredentials(token, "scope1,scope2")
/// 
/// Return: %Status. The status of the check.
ClassMethod CheckJWTCredentials(token As %String, scopes As %String, oauthClient As %String = "", jwks As %String = "") As %Status
{
  Set tStatus = $$$OK

  /// Sanity check the input  
  if (token = "") {
    Return $$$ERROR($$$GeneralError, "No token provided")
  }

  /// Check if the auth header starts with Bearer. Cleanup the token if yes.  
  if ($FIND(token, "Bearer") > 0) {
    /// Strip the "Bearer" part from the Authorization header and remove trailing and leading spaces.  
    set token = $ZSTRIP($PIECE(token, "Bearer", 2), "<>", "W")
  }

  /// Build a list from the string of scopes  
  Set scopes = $LISTFROMSTRING(scopes, ",")

  Set scopeList = ##class(%ListOfDataTypes).%New()
  Do scopeList.InsertList(scopes)

  /// Strip whitespaces from each scope  
  For i=1:1:scopeList.Count() {
    Do scopeList.SetAt($ZSTRIP(scopeList.GetAt(i), "<>", "W"), i)
  }

  /// Decode the token  
  Try {
    Do ..JWTToObject(token, .payload, .header)
  } Catch ex {
    Return $$$ERROR($$$GeneralError, "Not a valid JWT token. Exception code: " _ ex.Code _ ". Status: " _ ex.AsStatus())
  }

  /// Get the epoch time of now
  Set now = $ZDATETIME($h,-2)

  /// Check if the token has expired  
  if (payload.exp < now) {
    Return $$$ERROR($$$GeneralError, "Token has expired")
  }

  Set scopesFound = 0

  /// Check if the token has the required scopes
  for i=1:1:scopeList.Count() {
    Set scope = scopeList.GetAt(i)
    Set scopeIter = payload.scope.%GetIterator()
    While scopeIter.%GetNext(.key, .jwtScope) {
      if (scope = jwtScope) {
        Set scopesFound = scopesFound + 1
      }
    }
  }

  if (scopesFound < scopeList.Count()) {
    Return $$$ERROR($$$GeneralError, "Token does not have the required scopes")
  }

  /// If the token is valid scope-wise and it hasn't expired, check if the signature is valid
  if (oauthClient '= "") {
    /// If we have specified a OAuth client, use that to validate the token signature
    Set result = ##class(%SYS.OAuth2.Validation).ValidateJWT(oauthClient, token, , , , , .tStatus,)
    if ($$$ISERR(tStatus) || result '= 1) {
      Return $$$ERROR($$$GeneralError, "Token failed signature validation")
    }
  } elseif (jwks '= "") {
    /// If we have specified a JWKS, use that to validate the token signature
    Set tStatus = ##class(%OAuth2.JWT).JWTToObject(token,,jwks,,,)
    if ($$$ISERR(tStatus)) {
      Return $$$ERROR($$$GeneralError, "Token failed signature validation. Reason: " _ $SYSTEM.Status.GetErrorText(tStatus))
    }
  }

  Return tStatus
}


/// Decode a JWT token.
/// - token: The JWT token to decode.
/// - payload: The payload of the JWT token. (Output)
/// - header: The header of the JWT token. (Output)
/// 
/// Example:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).JWTToObject(token, .payload, .header)
/// 
/// Return: %Status. The status of the check.
ClassMethod JWTToObject(token As %String, Output payload As %DynamicObject, Output header As %DynamicObject) As %Status
{
  Set $LISTBUILD(header, payload, sign) = $LISTFROMSTRING(token, ".")

  /// Decode and parse Header  
  Set header = $SYSTEM.Encryption.Base64Decode(header)
  Set header = {}.%FromJSON(header)

  /// Decode and parse Payload  
  Set payload = $SYSTEM.Encryption.Base64Decode(payload)
  Set payload = {}.%FromJSON(payload)

  Return $$$OK
}
}
Discussion (7)2
Log in or sign up to continue

Thanks for sharing your thoughts. When you have lots of REST APIs to deal with, it's a real hassle to split them into secure and non-secure apps because it makes maintenance complicated. Plus, the way JWT authentication works in the web app seems pretty basic and doesn't handle scopes properly, as per the docs.

In cases where you're using an external OAuth 2.0 token provider, not IRIS, and you want an easy way to check those tokens in your API, this solution still makes sense.

However, it's important to note that JWT signatures should be validated if they are signed. You can achieve this in a couple of ways: by using an OAuth2 Client defined in IRIS, which contains data about the token server and by using the %SYS.OAuth2.Validation class's ValidateJWT method to validate the token. Alternatively, you can utilize a predefined JWKS_URI parameter inside the REST implementation class to facilitate the validation process.

I'll take a look at the JWT validation using these two methods and update the post accordingly.

Thanks,
Kari

There are two thoughts I have on this example:

  • The use of %All is a very bad idea.  This gives the maximum permissions to the connection.  Having just attended a seminar on penetration testing it is frightening how small gaps in security can be exploited to compromise a system.  Better to identify the specific resources  and roles needed for this action to complete and give only those.
  • Authentication is only taking place after the connection has "breached the castle walls".   The code is already running inside you environment and with %All privileges

Your utility methods are fine.  However I would implement them using Delegated Authentication.  This feature of IRIS allows you to provide your own code to do authentication.  The difference is that it executes as part of the normal authentication process.  The connection has not yet gained access to the environment and will not gain access if authentication is not passed.  Any failure or attempted breach of the code causes an "Access Denied" message to be returned and the connection terminated.  It is also possible to use this in combination with other authentication methods so this only needs to be used on REST services where it is needed.  This would also remove the need to add any special permissions like %All to the Web Application definition.  Here is the documentation for this.

Delegated Authentication

@Rich Taylor

You've raised some excellent points, and it's certainly something I need to consider for the future. Thank you for bringing up Delegated Authentication. I'll make the necessary modifications to the article to emphasize that Web Applications should not be granted %All permissions but rather only the permissions that are essential for the API usage.

I was actually pondering this question myself for an article I'm working on. I ended up in a very different place than you did, though. I created an Abstract class that extends %CSP.REST and overrides the XData schema, the DispatchMap method, and the DispatchRequest method. If I extend this class - which I've called REST.Resourceful - I can include a resource in the URL map as a resource:permission pair. For example:

<Route Url="/securetest" Method="GET" Call="securetest" Resource="MyResource:U" />

Will only allow you to access the /securetest endpoint if the user has Use permission on the resource "MyResource". If you don't, you get a 401 error. If I leave out the Resource attribute on that node, it doesn't check for any additional resources.

Source code is below. If you search the text for my initials, DLH, you'll see comments where I've made changes in the method, plus I added the attribute "Resource" to the XData schema.

/// Extends the %CSP.REST class to include securing of endpoints by resource in the URL map
Class REST.Resourceful Extends %CSP.REST [ Abstract ]
{ XData Schema [ Internal ]
{
<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema> <xs:element name="Routes">
<xs:complexType>
<xs:choice  minOccurs="0" maxOccurs="unbounded">
<xs:element name="Route">
<xs:complexType>
<xs:attribute name="Url"    type="string" use="required"/>
<xs:attribute name="Method" type="string" use="required"/>
<xs:attribute name="Call" type="call" use="required"/>
<xs:attribute name="Cors" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="Resource" type="string" use="optional" default=" "/>
</xs:complexType>
</xs:element>
<xs:element name="Map">
<xs:complexType>
<xs:attribute name="Prefix" type="string" use="required"/>
<xs:attribute name="Forward" type="forward" use="required"/>
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element> <xs:simpleType name="call">
<xs:restriction base="xs:string">
<xs:pattern value="([%]?[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*:)?[%]?[a-zA-Z][a-zA-Z0-9]*"/>
</xs:restriction>
</xs:simpleType> <xs:simpleType name="forward">
<xs:restriction base="xs:string">
<xs:pattern value="([%]?[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*)"/>
</xs:restriction>
</xs:simpleType> <xs:simpleType name="string">
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType> </xs:schema>
} /// This generator creates the DispatchMap Method used to dispatch the Url and Method to the associated target method
ClassMethod DispatchMap(pIndex As %Integer) As %String [ CodeMode = generator ]
{
#dim tSC As %Status = $$$OK
    #dim As %Exception.AbstractException
    
    #dim tStream As %Stream.TmpCharacter
    #dim tHandler As %XML.ImportHandler
    #dim tCompiledClass As %Dictionary.CompiledClass
    
    #dim tArgCount,tIndex,tI,tCounter As %Integer
    #dim tArgs,tChild,tClassName,tCall,tCors,tForward,tError,tMap,tMethod,tPattern,tPiece,tPrefix,tType,tUrl,tResource As %String
    
    Try {
        
        Set tClassName=%classname
        
        #; Don't run on base class
        If tClassName="%CSP.REST" Quit
       
        #; Find named XDATA block
        If ##class(%Dictionary.CompiledXData).%ExistsId(tClassName_"||UrlMap") {
            
            Set tCompiledClass=##class(%Dictionary.CompiledClass).%OpenId(tClassName,,.tSC)
            If '$IsObject(tCompiledClass)||$$$ISERR(tSC) Quit
            
            Set tIndex = tCompiledClass.XDatas.FindObjectId(tClassName_"||UrlMap")
            If tIndex="" Set tSC=$$$ERROR($$$XDataBlockMissing,tClassName,"UrlMap") Quit
        
            #; Get XDATA as stream
            Set tStream = tCompiledClass.XDatas.GetAt(tIndex).Data
            Do tStream.Rewind()
            
            #; Create an XML import handler ( use the internal handler )
            Set tHandler=##class(%XML.ImportHandler).%New("CacheTemp",$$$IntHandler)
        
            #; Create the Entity Resolver
            Set tResolver=##class(%XML.SAX.XDataEntityResolver).%New(tClassName)
        
            #; Parse the XML data in the specfied stream
            Set tSC=##Class(%XML.SAX.Parser).ParseStream(tStream,tHandler,tResolver,,,"Schema")
            If $$$ISERR(tSC) Quit       
        
            #; Copy tree because handler will delete its copy when it goes out of scope
            Merge tMap=@tHandler.DOMName@(tHandler.Tree)
    
            If $Data(tMap("error"))||$Data(tMap("warning")) {
                
                Set tSC=$$$ERROR($$$InvalidDispatchMap)
                For tType="error","warning" {       
                    Set tIndex = "" For {
                        Set tIndex=$Order(tMap(tType,tIndex),1,tError) If tIndex="" Quit
                        Set tSC=$$$ADDSC(tSC,$$$ERROR($$$GeneralError,tError))
                    }
                }
                Quit
            }
            
            #; Walk the xml and generate the routing map
            Set tChild="",tCounter=0 For {
                
                Set tChild=$Order(tMap(1,"c",tChild)) If tChild="" Quit
                
                If tMap(tChild)="Route" {
                    
                #; Need to substitute capture groups for arguments
                #; Added setting of tResource based on URL map - DLH
                Set tPattern="",tArgCount=0,tUrl=tMap(tChild,"a","Url"),tCors=tMap(tChild,"a","Cors"),tResource=tMap(tChild,"a","Resource")
                        
                #; Substitute variable placeholders for capture group
                For tI=1:1:$Length(tUrl,"/") {
                    Set tPiece=$Piece(tUrl,"/",tI)
                    If $Extract(tPiece)=":" {
                        Set $Piece(tPattern,"/",tI)="([^"_$Char(0)_"]+)"
                    else {
                        Set $Piece(tPattern,"/",tI)=tPiece                  }
                }
                Set tPattern=$Translate(tPattern,$Char(0),"/")                 Set tCounter=$Increment(tCounter),tMethod=tMap(tChild,"a","Method"),tCall=$Get(tMap(tChild,"a","Call"))
                #; Added getting resource from the URL Map here. - DLH
                $$$GENERATE(" If pIndex="_tCounter_" Quit $ListBuild(""R"","""_tPattern_""","""_tMethod_""","""_tCall_""","""_tCors_""","""_tResource_""")")
                
            else {
                
                Set tCounter=$Increment(tCounter),tPrefix=tMap(tChild,"a","Prefix"),tForward=$Get(tMap(tChild,"a","Forward"))                 #; Need to substitute capture groups for arguments
                Set tPattern=""
                For tI=2:1:$Length(tPrefix,"/") {
                    Set tPiece=$Piece(tPrefix,"/",tI)
                    If $Extract(tPiece)=":" {
                        Set tPattern=tPattern_"/[^/]+"
                    else {
                        Set tPattern=tPattern_"/"_tPiece
                    }
                }
                
                Set tPattern = "("_ tPattern _ ")/.*"
                
                $$$GENERATE(" If pIndex="_tCounter_" Quit $ListBuild(""M"","""_tPattern_""","""_tForward_""")")
            }
            }
            $$$GENERATE(" Quit """"")
                
        else {
            
            #; The specified class must have an XDATA Block named UrlMap
            Set tSC=$$$ERROR($$$XDataBlockMissing,tClassName,"UrlMap")
        }
        
    Catch (e) {
        Set tSC=e.AsStatus()
    }
    
    Quit tSC
} /// Dispatch a REST request according to URL and Method
ClassMethod DispatchRequest(pUrl As %String, pMethod As %String, pForwarded As %Boolean = 0) As %Status
{
    #dim tSC As %Status = $$$OK
    #dim As %Exception.AbstractException
    
    #dim tMatcher As %Regex.Matcher
    
    #dim tArgs,tClass,tMatchUrl,tMapEntry,tRegEx,tCall,tForward,tAccess,tSupportedVerbs,tTarget,tType As %String
    #dim tI,tIndex As %Integer
    #dim tResourceMatched,tContinue As %Boolean
    #dim tMethodMatched As %Boolean
    
    Try {
        
        Set (tResourceMatched,tMethodMatched)=0
        #; Initializing tSecurityResourceMatched - DLH
        Set tSecurityResourceMatched = 1
        #; Extract the match url from the application name
        If (0=pForwarded) {
            Set tMatchUrl="/"_$Extract(pUrl,$Length(%request.Application)+1,*)
        else {
            Set tMatchUrl=pUrl
        }
      
        #; Uppercase the method
        Set pMethod=$ZCVT(pMethod,"U")
          
        #; Pre-Dispatch
        Set tContinue=1,tSC=..OnPreDispatch(tMatchUrl,pMethod,.tContinue)
        If $$$ISERR(tSC) Quit
        
        #; It's the users responsibility to return the response in OnPreDispatch() if Continue = 0
        If tContinue=0 Quit
            
        #; Walk the dispatch map in collation order of defintion
        For tIndex=1:1 {
            
            #; Get the next map entry
            Set tMapEntry=..DispatchMap(tIndex) If tMapEntry="" Quit
             
            #; Pick out the RegEx
            Set tRegEx=$List(tMapEntry,2)
            
            #; Create a matcher
            Set tMatcher=##class(%Regex.Matcher).%New(tRegEx)
            
            #; Test each regular expression in turn, extracting the arguments,
            #; dispatching to the named method
            If tMatcher.Match(tMatchUrl) {
#; We have matched the resource
                Set tResourceMatched=1
                #; Logic to check the resource from the URL map
                set tResource = $List(tMapEntry,6)
                If tResource '= " "{
                If $SYSTEM.Security.Check($P(tResource,":",1),$P(tResource,":",2))=0{
         Set tSecurityResourceMatched=0
         }
                }
  #; Added an if so the method only gets dispatched if we have the resource permission
                If tSecurityResourceMatched = 1{
                
                Set tType=$List(tMapEntry,1)
                 
                 #; If we are a simple route
                 If tType="R" {
                    
                     #; Support OPTIONS VERB (cannot be overriden)
                    If pMethod="OPTIONS" {
                         
                         Set tMethodMatched=1
                        
                         Set tSC=..OnHandleOptionsRequest(tMatchUrl)
                         If $$$ISERR(tSC) Quit
                        
                         #; Dispatch CORS
                        Set tSC=..ProcessCorsRequest(pUrl,$list(tMapEntry,5))
                         If $$$ISERR(tSC) Quit
                        
                         Quit
                     }
                     
                     #; comparison is case-insensitive now
                    If pMethod'=$ZCVT($List(tMapEntry,3),"U") Continue
                     
                     Set tTarget=$List(tMapEntry,4)
                     
                     #; We have matched a method
                     Set tMethodMatched=1
                    
                    #; Dispatch CORS
                     Set tSC=..ProcessCorsRequest(pUrl,$list(tMapEntry,5))
                     If $$$ISERR(tSC) Quit
                   
                     #; Got a match, marshall the arguments can call directly
                     If tMatcher.GroupCount {
                         For tI=1:1:tMatcher.GroupCount Set tArgs(tI)=tMatcher.Group(tI)
                         Set tArgs=tI
                     else {
                         Set tArgs=0
                     }
                    
                     #; Check for optional ClassName prefix
                     Set tClass=$classname()
                     If tTarget[":" Set tClass=$Piece(tTarget,":"),tTarget=$Piece(tTarget,":",2)
                    
                     #; Dispatch
                     Set tSC=$zobjclassmethod(tClass,tTarget,tArgs...)
                       
                 else {
                    
                     #; We are a map, massage the URL and forward the request
                     Set tMatchUrl=$piece(tMatchUrl,tMatcher.Group(1),"2",*),tForward=$ListGet(tMapEntry,3)
                     Set (tResourceMatched,tMethodMatched)=1
                   
                     #; Dispatch with modified URL
                     Set tSC=$zobjclassmethod(tForward,"DispatchRequest",tMatchUrl,pMethod,1)
                 }
                
                 If $$$ISERR(tSC) Quit
                
                 #; Don't want multiple matches
                 Quit
                }
            }
        }
        
        #; Didn't have permission for the resource required by this enpoint; return 401 - DLH
        If tSecurityResourceMatched = 0 Set tSC=..ReportHttpStatusCode(..#HTTP401UNAUTHORIZED) Quit
        
        #; Didn't have a match for the resource, report not found
        If tResourceMatched=0 Set tSC=..ReportHttpStatusCode(..#HTTP404NOTFOUND) Quit
                  
        #; Had a match for resource but method not matched
        If tMethodMatched=0 {
            
            Set tSC=..SupportedVerbs(tMatchUrl,.tSupportedVerbs)
            If $$$ISERR(tSC) Quit
            
            Set tSC=..Http405(tSupportedVerbs) Quit
        }
            
    Catch (e) {
        
        Set tSC=e.AsStatus()
    }
    
    Quit tSC
} }
 

An interesting approach, but it involves custom tinkering with internal classes. Ultimately, it comes down to personal preference, but I tend to favor building on top of verified and tested implementations, reserving the extension of InterSystems base classes as a last resort.

In my approach, there's no need to modify any class implementations. Instead, you can simply use a utility method for validation. As previously discussed, these methods should be invoked as part of the normal authentication process, not within the API implementation, as Rich Taylor mentioned.

If I understand your solution correctly, the client sends a request, and the logic checks whether the application has the correct privileges. Does it also verify whether the provided credentials are accurate? In my method, I verify Basic Auth credentials via $SYSTEM.Security.Login() and then use $SYSTEM.Security.Check() to confirm whether the user in question possesses the required privileges.

I'll need to explore Delegated Authentication in IRIS and adjust the article accordingly.

I didn't check the credentials because this all takes place after the user has already been through the authentication process. If they aren't valid, they wouldn't get this far anyway. My approach is dealing with just the authorization, not the authentication. I still use $SYSTEM.Security.Check to see if the process has the right permissions.

As you said, it comes down to personal preference. One of my preferences is not messing with the authentication processes if I don't have to. That way I don't have to account for all of the different authentication options, and they could all still be used.