JWT Authentication for REST Services

Version: 17.07

Supported Since: 17.07

Use Case Description

The REST service described under REST Service Mediation has to be secured by allowing access only to properly authenticated users. Unauthenticated requests to access the API should be declined with HTTP 401 responses with appropriate error messages.

Proposed Solution

JWT authentication is utilized for the access control mechanism, where the user needs to include an Authorization header with a valid JWT token for the request to be considered authenticated. If either the header cannot be found or parsed, or it does not correspond to a valid username-password pair, UltraESB blocks the request and returns an HTTP 401 response along with an appropriate error message. Otherwise the request is allowed to proceed as in the case of the original REST service mediation flow.

Implementation

JWT authentication involves two main stages:

  • obtaining the token via a regular authentication mechanism, and

  • making the actual request using the token.

In the context of integration projects this could be implemented as two integration flows, each exposing a different HTTP endpoint.

In real-world scenarios JWT authentication would be coupled with advanced authentication and RBAC mechanisms such as those offered by Shiro or Spring Security. For simplicity, here we shall utilize a simple, in-memory basic authentication mechanism as the base, similar to what we utilize under Basic Authentication for REST Services.

Prerequisites

  1. Complete the REST Service Mediation introductory sample, on which we will base our new solution.

  2. Obtain a copy of the above sample project.

  3. Remove the .idea and <project-name>.iml file in the root directory, so that UltraStudio would not recognize the new copy as the old project.

  4. Modify the artifactId in pom.xml, and projectId and projectName in project.xpml, to new values.

  5. Open the pom.xml file of the new copy via UltraStudio’s Open option, and select the Open as Project option when prompted, to import the entire project as a new Ultra project.

Alternatively you can:

  1. create a new sample project, selecting the REST Service Mediation sample from the UltraStudio sample repository, or

  2. redo the REST Service Mediation sample from scratch, and resume from there.

Custom Processing Elements

For this sample, we will be writing a few custom processing elements to handle the user authentication, token generation and token decode stages:

UserAuthenticator.java

This is a simple user authentication mechanism similar to the basic authentication mode of the HTTP authenticator. In this case it reads the user credentials from a static CSV file at server startup, and keeps them in memory for use during authentication operations. Additionally, it also passes the username detected during the authentication process as a message context property, for future use in the integration logic.

package com.esb.samples;

import org.adroitlogic.x.annotation.config.OutPort;
import org.adroitlogic.x.annotation.config.Parameter;
import org.adroitlogic.x.api.ExecutionResult;
import org.adroitlogic.x.api.IntegrationRuntimeException;
import org.adroitlogic.x.api.XMessage;
import org.adroitlogic.x.api.XMessageContext;
import org.adroitlogic.x.annotation.config.Processor;
import org.adroitlogic.x.api.config.InputType;
import org.adroitlogic.x.api.config.ProcessorType;
import org.adroitlogic.x.api.processor.XProcessingElement;
import org.adroitlogic.x.base.processor.AbstractProcessingElement;
import org.springframework.context.ApplicationContext;

import java.io.FileInputStream;
import java.io.InputStream;
import java.util.Map;
import java.util.Scanner;

/**
 * At initialization, this processing element loads a set of credentials from a comma-separated username-password file
 * into a project-wide shared map (cache), and utilizes it at runtime to authenticate users
 */
@Processor(displayName = "User Authenticator", type = ProcessorType.CUSTOM)
public class UserAuthenticator extends AbstractProcessingElement {

    @OutPort(displayName = "Next Element")
    private XProcessingElement nextElement;

    @Parameter(displayName = "Credential File", propertyName = "credentialFile",
            description = "Location of a credential file with comma-separated username-password pairs")
    private String credentialFile;

    @Parameter(displayName = "User Cache", inputType = InputType.RESOURCE,
            description = "Shared Map of username-password credentials")
    private Map<String, String> userCache;

    @Override
    public ExecutionResult process(XMessageContext messageContext) {
        final XMessage msg = messageContext.getMessage();
        try {
            final String authHeader = AuthHeaderUtil.getAuthHeaderValue(messageContext, "Basic ");

            final String[] userCredentials = authHeader.split(":", 2);
            if (userCredentials.length != 2) {
                throw new RuntimeException(AuthHeaderUtil.AUTH_HEADER + " header is not properly formatted");
            }

            if (!userCredentials[1].equals(userCache.get(userCredentials[0]))) {
                throw new RuntimeException("Invalid username/password");
            }
            msg.removeTransportHeader(AuthHeaderUtil.AUTH_HEADER);

            // adding the username a property which can be used to generate JWT token
            msg.addMessageProperty(AuthHeaderUtil.USERNAME_KEY, userCredentials[0]);

            return nextElement.processMessage(messageContext);

        } catch (RuntimeException e) {
            // on authentication failure
            msg.setResponseCode(401);
            msg.addMessageProperty(AuthHeaderUtil.ERROR_KEY, e.getMessage());
            return getErrorHandler().processMessage(messageContext);
        }
    }

    @Override
    protected void initElement(ApplicationContext context) {
        try {
            InputStream is = new FileInputStream(getResource(credentialFile).getPath());
            Scanner scanner = new Scanner(is);
            while (scanner.hasNextLine()) {
                String[] values = scanner.nextLine().split(",");
                userCache.put(values[0], values[1]);
            }
        } catch (Exception e) {
            throw new IntegrationRuntimeException("Failed to initialize credential cache", e);
        }
    }

    public XProcessingElement getNextElement() {
        return nextElement;
    }

    public void setNextElement(XProcessingElement nextElement) {
        this.nextElement = nextElement;
    }

    public void setCredentialFile(String credentialFile) {
        this.credentialFile = credentialFile;
    }

    public void setUserCache(Map<String, String> userCache) {
        this.userCache = userCache;
    }
}
JWTTokenGenerator.java

This generates a JWT token using the username handed over by the User Authenticator and sets it as the message payload.

package com.esb.samples;

import io.jsonwebtoken.JwtBuilder;
import org.adroitlogic.x.annotation.config.OutPort;
import org.adroitlogic.x.annotation.config.Parameter;
import org.adroitlogic.x.api.ExecutionResult;
import org.adroitlogic.x.api.XMessage;
import org.adroitlogic.x.api.XMessageContext;
import org.adroitlogic.x.annotation.config.Processor;
import org.adroitlogic.x.api.config.ProcessorType;
import org.adroitlogic.x.api.processor.XProcessingElement;
import org.adroitlogic.x.base.format.StringFormat;
import org.adroitlogic.x.base.processor.AbstractProcessingElement;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.Optional;

import static com.esb.samples.AuthHeaderUtil.ERROR_KEY;
import static org.adroitlogic.x.annotation.config.Parameter.Validators.POSITIVE_INT_EXCLUDING_ZERO;

/**
 * This processing element generates a JWT token using the username passed as a message context parameter and sets
 * it as the message payload
 */
@Processor(displayName = "JWT Token Generator", type = ProcessorType.CUSTOM)
public class JWTTokenGenerator extends AbstractProcessingElement {

    @OutPort(displayName = "Next Element")
    private XProcessingElement nextElement;

    @Parameter(displayName = "Secret Key", description = "Specify the Secret key to be used", propertyName = "secretKey")
    private String secretKey;

    @Parameter(displayName = "Valid Period", description = "Specify the valid time period for the token in milliseconds",
            validator = POSITIVE_INT_EXCLUDING_ZERO, propertyName = "validTimePeriod")
    private long validTimePeriod;

    @Override
    public ExecutionResult process(XMessageContext messageContext) {
        try {
            final Optional<String> userName = messageContext.getMessage().getStringMessageProperty(AuthHeaderUtil.USERNAME_KEY);
            if (!userName.isPresent()) {
                throw new RuntimeException(AuthHeaderUtil.USERNAME_KEY + " unavailable");
            }

            //The JWT signature algorithm we will be using to sign the token
            SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

            long nowMillis = System.currentTimeMillis();
            Date now = new Date(nowMillis);

            // sign our JWT with our  secret
            byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);
            Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());


            //Let's set the JWT Claims
            JwtBuilder builder = Jwts.builder().setId(userName.get())
                    .setIssuedAt(now)
                    .setSubject(userName.get())
                    .setIssuer("AdroitLogic")
                    .signWith(signatureAlgorithm, signingKey);

            //if it has been specified, let's add the expiration
            if (validTimePeriod >= 0) {
                long expMillis = nowMillis + validTimePeriod;
                Date exp = new Date(expMillis);
                builder.setExpiration(exp);
            }

            //Builds the JWT and serializes it to a compact, URL-safe string and set it as new payload
            messageContext.getMessage().setPayload(new StringFormat(builder.compact()));

            return nextElement.processMessage(messageContext);

        } catch (RuntimeException e) {
            // server error
            final XMessage msg = messageContext.getMessage();
            msg.setResponseCode(500);
            msg.addMessageProperty(ERROR_KEY, e.getMessage());
            return getErrorHandler().processMessage(messageContext);
        }
    }

    public XProcessingElement getNextElement() {
        return nextElement;
    }

    public void setNextElement(XProcessingElement nextElement) {
        this.nextElement = nextElement;
    }

    public void setSecretKey(String secretKey) {
        this.secretKey = secretKey;
    }

    public void setValidTimePeriod(long validTimePeriod) {
        this.validTimePeriod = validTimePeriod;
    }
}
JWTTokenDecoder.java

This decodes a JWT token received in a backend request, and validates it using the credential cache loaded at startup. The request is allowed to pass only if the token is valid and belongs to a user currently in the credential cache.

package com.esb.samples;

import org.adroitlogic.x.annotation.config.OutPort;
import org.adroitlogic.x.annotation.config.Parameter;
import org.adroitlogic.x.api.ExecutionResult;
import org.adroitlogic.x.api.XMessage;
import org.adroitlogic.x.api.XMessageContext;
import org.adroitlogic.x.annotation.config.Processor;
import org.adroitlogic.x.api.config.InputType;
import org.adroitlogic.x.api.config.ProcessorType;
import org.adroitlogic.x.api.processor.XProcessingElement;
import org.adroitlogic.x.base.processor.AbstractProcessingElement;

import javax.xml.bind.DatatypeConverter;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;

import java.util.Map;

import static com.esb.samples.AuthHeaderUtil.ERROR_KEY;

/**
 * This processing element decodes the JWT token received as a bearer token in the Authorization transport header of
 * the request, validates it against the shared user cache, and allows the message flow to proceed upon success
 */
@Processor(displayName = "JWT Token Decoder", type = ProcessorType.CUSTOM)
public class JWTTokenDecoder extends AbstractProcessingElement {

    @OutPort(displayName = "Next element")
    private XProcessingElement nextElement;

    @Parameter(displayName = "Secret Key", description = "Specify the Secret key to be used", propertyName = "secretKey")
    private String secretKey;

    @Parameter(displayName = "User Cache", inputType = InputType.RESOURCE,
            description = "Shared Map of username-password credentials")
    private Map<String, String> userCache;

    @Override
    public ExecutionResult process(XMessageContext messageContext) {
        try {
            final String token = AuthHeaderUtil.getAuthHeaderValue(messageContext, "Bearer ");

            Claims claims = Jwts.parser()
                    .setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
                    .parseClaimsJws(token).getBody();

            // check if user is known
            if (!userCache.containsKey(claims.getId())) {
                throw new RuntimeException("Token source mismatch");
            }

            return nextElement.processMessage(messageContext);

        } catch (RuntimeException e) {
            // on authentication failure
            final XMessage msg = messageContext.getMessage();
            msg.setResponseCode(401);
            msg.addMessageProperty(ERROR_KEY, e.getMessage());
            return getErrorHandler().processMessage(messageContext);
        }
    }

    public void setSecretKey(String secretKey) {
        this.secretKey = secretKey;
    }

    public XProcessingElement getNextElement() {
        return nextElement;
    }

    public void setNextElement(XProcessingElement nextElement) {
        this.nextElement = nextElement;
    }

    public void setUserCache(Map<String, String> userCache) {
        this.userCache = userCache;
    }
}

On failure, each of the above processing elements attach error details to the message context as message properties and send it through a payload setter which will transform the payload to an appropriate error message before sending it back as the HTTP response.

AuthHeaderUtil.java

This is a utility class shared by the above custom processing elements, for extracting Basic and Bearer (JWT) Authorization headers from inbound requests. It also holds some shared constant (message context key) values.

package com.esb.samples;

import org.adroitlogic.x.api.XMessageContext;

import javax.xml.bind.DatatypeConverter;
import java.util.UUID;

public class AuthHeaderUtil {

    static final String AUTH_HEADER = "Authorization";
    static final String USERNAME_KEY = "auth.username";
    static final String ERROR_KEY = "auth.error";

    public static String getAuthHeaderValue(XMessageContext ctx, String authTypeKey) {
        final String authHeader = ctx.getMessage().getFirstStringTransportHeader(AUTH_HEADER)
                .orElseThrow(() -> new RuntimeException(AUTH_HEADER + " header unavailable for " + authTypeKey + "auth"));
        if (!authHeader.startsWith(authTypeKey)) {
            throw new RuntimeException("A " + authTypeKey + AUTH_HEADER + " header is expected");
        }
        return new String(DatatypeConverter.parseBase64Binary(authHeader.substring(authTypeKey.length())));
    }
}

Resource Definitions

UserAuthenticator and JWTTokenDecoder utilize a shared in-memory credential cache defined as a Map resource which should be defined under project.xpml:

<x:resource id="userCache">
    <map/>
</x:resource>

Credential information is loaded from a CSV file at server startup (by the initElement() method of UserAuthenticator) which should be created under src/test/resources (so that it will not be bundled with the project archive, and can be provided as an external file placed in $X_HOME/conf when being deployed on a standalone server). It should contain a username-password pair on each line, as follows:

admin,password
alice,alicepass
bob,bobpass

Integration Flows

Before proceeding, click BuildBuild Project to compile the Java classes and make the custom processing elements available for the integration flow designer.
User Authentication
  1. Create and open (in Design view) a new integration flow named rest-auth-jwt-flow.

  2. Add a NIO HTTP listener with the following configuration:

    Http port

    8280

    Service path

    /service/auth

  3. Add a User Authenticator custom processing element with the following configuration:

    Credential File

    credentials.csv

    User Cache

    userCache

  4. Add a JWT Token Generator custom processing element with the following configuration:

    Secret Key

    thisisasecretkey (can be any value, which should however be defined identically on the token decoder configuration)

    Validity Period

    3600000 (token expiry time in milliseconds)

  5. Add a String Payload Setter with the following configuration:

    String Payload

    Authentication failed: @{message.properties.auth.error}

  6. Connect the elements as shown in the following figure:

    jwt auth flow
REST Requests

This flow can be derived easily from either the basic authentication flow or the REST service mediation flow:

  1. Modify the Service path of the NIO HTTP listener, to /service/request.

  2. If starting from the basic authentication flow, delete the HTTP authenticator processing element.

  3. Add a JWT Token Decoder custom processing element to the flow, with the following configuration:

    Secret Key

    thisisasecretkey (same as what was defined on the token generator configuration)

    User Cache

    userCache

  4. Add a string payload setter with the following configuration:

    String Payload

    Authentication failed: @{message.properties.auth.error}

  5. Rewire the flow to resemble the following:

    jwt request flow

Now run the previously created RESTProxy configuration (or create a new one, if you are on a new project) to get the ESB running.

Testing

  1. Send a HTTP GET request to http://localhost:8280/service/request?q=London&APPID=4f39ab00e0ea663ad801b6bd12799cb6
    However, since we are not yet sending any credentials, you should receive an "unauthorized" error response:

    HTTP/1.1 401 Unauthorized
    ...
    
    Authentication failed: Authorization header unavailable for Bearer auth
  2. Send a HTTP GET request to http://localhost:8280/service/auth, with the admin:password credential pair (or one of the other pairs defined in credentials.csv). (You can use the Http Authentication option under Authentication tab of the Toolbox HTTP/S client for this.) You will receive a successful response containing a JWT token as the payload.

    HTTP/1.1 200 OK
    ...
    
    eyJhbGciOiJIUzI1NiJ9...
  3. Encode the received token in base 64 (for this, you can use the built-in btoa() function on the JavaScript console of your web browser, or any other online/offline tool).

  4. Use the encoded value to prepare a bearer authentication header (Authorization: Bearer base64_encoded_token_here) and make another request to http://localhost:8280/service/request?q=London&APPID=4f39ab00e0ea663ad801b6bd12799cb6 including the above header. (You can use the Custom Headers option of the Toolbox HTTP/S client for this.) Now you should receive a successful response from the OpenWeatherMap API, since the JWT authenticator can successfully decode and validate the token, authenticate you, and lets the request through:

    HTTP/1.1 200 OK
    ...
    
    {"coord":{"lon":-0.13,"lat":51.51},...
In this topic
In this topic
Contact Us