Google Cloud Using Identity-Aware-Proxy and JAX-RS to authenticate users

By deploying a web application behind Identity-Aware-Proxy (IAP), we can ensure that an application only receives requests that are authenticated and satisfy the context-aware access rules we’ve configured. In zero-trust lingo, that means IAP is a policy enforcement point.

But there are still a few things that the web application needs to do itself.

In a previous post, we looked at how IAP works, and saw that IAP injects a special X-Goog-Iap-Jwt-Assertion header into each request that it passes to the application:

This header contains an IAP JWT assertion that looks a bit like an IdToken, but is not an IdToken.
The primary purpose of an IdToken is to enable a relying party to identify the user. In contrast, the primary purpose of the IAP JWT assertion is to enable the backend application to verify that the request has been properly vetted by Cloud IAP.

But is it really necessary that a web application verifies the IAP JWT assertion for each request? Let’s revisit the threat vectors related to IAP:

Accidental disabling of Cloud IAP: You might accidentally disable Cloud IAP, causing all requests to be passed unvetted.

To protect against this threat, an application should check for the existence of the JWT assertion.

Sidestepping Cloud IAP: If firewall rules are not properly configured, a malicious client might sidestep Cloud IAP and send a request directly to the backend application. To overcome the check for the assertion, it might send a fake assertion:

To protect against sidestepping and fake assertions, a backend application should verify the signature of the JWT against Cloud IAP’s JWKS.

Because IAP JWT assertions are JWTs, any JWT-compliant OAuth or OpenID Connect library should be able to perform such validation.

Replay: A malicious client that somehow got hold of an IAP JWT assertion might keep replaying the assertion.

To protect against such replays, a backend application should verify the expiration of the assertion as encoded in the exp claim.

Assertions from a different IAP instance: Finally, it is possible that the request passed some Cloud IAP instance, but not the right one. This is a rare case and probably requires some configuration screw-up to happen, but still… To verify that an IAP JWT assertion is indeed intended for this backend application and not for some other party, a backend application should verify the audience claim (aud).

So yes, there are good reasons why an application should verify the IAP JWT assertion that IAP injects into request headers. Now let’s see how we can do this for in a Java application that uses JAX-RS.

Verifying IAP assertions using a JAX-RS ContainerRequestFilter

JAX-RS lets us intercept and filter requests by creating a custom ContainerRequestFilter. So let’s create a filter that:

  • reads the X-Goog-Iap-Jwt-Assertion header and verifies the assertion. If there’s anything wrong with the assertion, we fail the request with a HTTP 403 response.
  • extracts the user’s email address from the assertion and makes it available to the REST resources.

Instead of writing any custom JWT verification code, let’s use the Google Cloud client libraries:

<dependency>
  <groupId>com.google.auth</groupId>
  <artifactId>google-auth-library-oauth2-http</artifactId>
</dependency>
<dependency>
  <groupId>com.google.http-client</groupId>
  <artifactId>google-http-client</artifactId>
</dependency>

Using these libraries, we can now write a custom filter:

import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;
import org.jboss.logging.Logger;

import javax.annotation.Priority;
import javax.enterprise.context.Dependent;
import javax.inject.Inject;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.ext.Provider;
import java.security.Principal;

/**
 * Verifies that requests have a valid IAP assertion, and makes the assertion
 * available as SecurityContext.
 */
@Dependent
@Provider
@Priority(Priorities.AUTHENTICATION)
public class IapRequestFilter implements ContainerRequestFilter
{
  private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap";
  private static final String IAP_ASSERTION_HEADER = "x-goog-iap-jwt-assertion";

  private static final Logger LOG = Logger.getLogger(IapRequestFilter.class);

  @Inject
  IapRequestFilterConfig config;

  private Principal authenticateRequest(
      ContainerRequestContext requestContext)
  {
    //
    // Read IAP assertion header and validate it.
    //
    String assertion = requestContext.getHeaderString(IAP_ASSERTION_HEADER);
    if (assertion == null)
    {
      throw new ForbiddenException("IAP assertion missing, application must be accessed via IAP");
    }

    try
    {
      final JsonWebToken jsonWebToken = TokenVerifier
          .newBuilder()
          .setAudience(this.config.getExpectedAudience())
          .setIssuer(IAP_ISSUER_URL)
          .build()
          .verify(assertion);

      //
      // Associate the token with the request so that controllers
      // can access it.
      //
      return new Principal()
      {
        @Override
        public String getName()
        {
          return jsonWebToken.getPayload().get("email").toString();
        }
      };
    }
    catch (TokenVerifier.VerificationException | IllegalArgumentException e)
    {
      throw new ForbiddenException("Invalid IAP assertion", e);
    }
  }

  public void filter(ContainerRequestContext requestContext)
  {
    Principal principal = authenticateRequest(requestContext);

    requestContext.setSecurityContext(new SecurityContext()
    {
      @Override
      public Principal getUserPrincipal()
      {
        return principal;
      }

      @Override
      public boolean isUserInRole(String s)
      {
        return false;
      }

      @Override
      public boolean isSecure()
      {
        return true;
      }

      @Override
      public String getAuthenticationScheme()
      {
        return "IAP";
      }
    });

    LOG.infof("Authenticated IAP principal: %s", principal.getName());
  }
}

There is one piece of configuration that the IapRequestFilter needs, and that’s the expected audience. Typically, we’d read this information from a configuration file or environment variable. But if our apps runs on AppEngine, we can determine the expected audience automatically based on the project number and project ID:

/projects/PROJECT_NUMBER/apps/PROJECT_ID

Both the project number and project ID can be read from the metadata server.

Obtaining user information from the resource context

As IAP and our IapRequestFilter take care of authentication, we don’t need to add any extra authentication logic to our REST methods. But we might still need to know who the authenticated user is. For that, we can inject a SecurityContext and access the principal like so:

import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.SecurityContext;

@Path("/api/")
public class ApiResource
{
  @GET
  @Produces(MediaType.TEXT_PLAIN)
  public String whoami(@Context SecurityContext securityContext)
  {
    return securityContext.getUserPrincipal().getName();
  }
}
Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home