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.Check(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", "U")
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.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
}
}