DEV Community

InterSystems Developer for InterSystems

Posted on • Originally published at community.intersystems.com

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  
{

}
Enter fullscreen mode Exit fullscreen mode

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.Check(resource, permissions)

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

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", "U")
  if ($$$ISERR(tStatus)) {
    Set %response.Status = 401
    Return
  }
  ... rest of the code
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.Check(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
}
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)